From a22dad336c6abda4bef94672377f86128f49a977 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 5 Feb 2025 23:06:57 +0100 Subject: [PATCH 001/313] refactor: extract intersectionObserver into class --- src/LazyTranslator.ts | 107 +++++++++++++++++++++++++++++++++++++++++ src/NodesTranslator.ts | 57 +++------------------- 2 files changed, 115 insertions(+), 49 deletions(-) create mode 100644 src/LazyTranslator.ts diff --git a/src/LazyTranslator.ts b/src/LazyTranslator.ts new file mode 100644 index 0000000..a6a1cc6 --- /dev/null +++ b/src/LazyTranslator.ts @@ -0,0 +1,107 @@ +import { InnerConfig } from '.'; + +type Translator = (node: Node) => void; + +type IntersectionConfig = { + root?: null | Element; + rootMargin?: string; + threshold?: number; +}; + +function isIntersectableNode(node: Element) { + // return true for all element not + if (node.nodeName === 'OPTION') return false; + + return document.body.contains(node); +} + +/** + * The class provides a way to translate only those elements that intersect with an ancestor element, + * by default, the top-level document's viewport. + */ +export class LazyTranslator { + private readonly translator: Translator; + private readonly config: InnerConfig; + + private readonly itersectStorage = new WeakSet(); + + private itersectObserver: IntersectionObserver; + + constructor( + translator: Translator, + config: InnerConfig, + intersectionConfig: IntersectionConfig = { + root: null, + rootMargin: '0px', + threshold: 0, + }, + ) { + this.translator = translator; + this.config = config; + + this.itersectObserver = new IntersectionObserver((entries, observer) => { + entries.forEach((entry) => { + const node = entry.target; + + if (!this.itersectStorage.has(node) || !entry.isIntersecting) return; + + this.itersectStorage.delete(node); + observer.unobserve(node); + + this.handlerIntersectNode(node); + }); + }, intersectionConfig); + } + + private handlerIntersectNode(node: Node) { + // Translate child text nodes and attributes of target node + // WARNING: we shall not touch inner nodes, because its may still not intersected + node.childNodes.forEach((node) => { + if (node instanceof Element || !this.config.isTranslatableNode(node)) { + return; + } + + this.translator(node); + }); + } + + private handleElementByIntersectViewport(node: Element) { + if (this.itersectStorage.has(node)) return; + this.itersectStorage.add(node); + + this.itersectObserver.observe(node); + } + + /** + * The lazyTranslationHandler method decides whether the node should be processed immediately or later + */ + // + public process(node: Node) { + // Lazy translate when own element intersect viewport + // But translate at once if node have not parent (virtual node) or parent node is outside of body (utility tags like meta or title) + + if (this.config.lazyTranslate) { + const isAttachedToDOM = node.getRootNode() !== node; + const observableNode = + node instanceof Attr ? node.ownerElement : node.parentElement; + + // Ignore lazy translation for not intersectable nodes and translate it immediately + if ( + isAttachedToDOM && + observableNode !== null && + isIntersectableNode(observableNode) + ) { + this.handleElementByIntersectViewport(observableNode); + + return true; + } + } + return false; + } + + public disable(node: Element) { + this.itersectStorage.delete(node); + + this.itersectObserver.unobserve(node); + } +} diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index edfa1fa..f5f10d8 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,3 +1,4 @@ +import { LazyTranslator } from './LazyTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; import { configureTranslatableNodePredicate } from './utils/nodes'; @@ -71,7 +72,7 @@ export function isInViewport(element: Element, threshold = 0) { type TranslatorInterface = (text: string, priority: number) => Promise; -interface InnerConfig { +export interface InnerConfig { isTranslatableNode: (node: Node) => boolean; lazyTranslate: boolean; } @@ -91,6 +92,7 @@ export interface Config { export class NodesTranslator { private readonly translateCallback: TranslatorInterface; private readonly config: InnerConfig; + private lazyTranslator: LazyTranslator; constructor(translateCallback: TranslatorInterface, config?: Config) { this.translateCallback = translateCallback; @@ -101,6 +103,8 @@ export class NodesTranslator { lazyTranslate: config?.lazyTranslate !== undefined ? config?.lazyTranslate : true, }; + + this.lazyTranslator = new LazyTranslator(this.handleNode, this.config); } private readonly observedNodesStorage = new Map(); @@ -156,36 +160,6 @@ export class NodesTranslator { return { originalText }; } - private readonly itersectStorage = new WeakSet(); - private readonly itersectObserver = new IntersectionObserver( - (entries, observer) => { - entries.forEach((entry) => { - const node = entry.target; - if (!this.itersectStorage.has(node) || !entry.isIntersecting) return; - - this.itersectStorage.delete(node); - observer.unobserve(node); - this.intersectNode(node); - }); - }, - { root: null, rootMargin: '0px', threshold: 0 }, - ); - - private intersectNode = (node: Element) => { - // Translate child text nodes and attributes of target node - // WARNING: we shall not touch inner nodes, because its may still not intersected - node.childNodes.forEach((node) => { - if (node instanceof Element || !this.isTranslatableNode(node)) return; - this.handleNode(node); - }); - }; - - private handleElementByIntersectViewport(node: Element) { - if (this.itersectStorage.has(node)) return; - this.itersectStorage.add(node); - this.itersectObserver.observe(node); - } - private idCounter = 0; private nodeStorage = new WeakMap(); private handleNode = (node: Node) => { @@ -226,22 +200,8 @@ export class NodesTranslator { // Handle text nodes and attributes - // Lazy translate when own element intersect viewport - // But translate at once if node have not parent (virtual node) or parent node is outside of body (utility tags like meta or title) - if (this.config.lazyTranslate) { - const isAttachedToDOM = node.getRootNode() !== node; - const observableNode = - node instanceof Attr ? node.ownerElement : node.parentElement; - - // Ignore lazy translation for not intersectable nodes and translate it immediately - if ( - isAttachedToDOM && - observableNode !== null && - this.isIntersectableNode(observableNode) - ) { - this.handleElementByIntersectViewport(observableNode); - return; - } + if (this.lazyTranslator.process(node)) { + return; } // Add to storage @@ -258,8 +218,7 @@ export class NodesTranslator { } // Unobserve - this.itersectStorage.delete(node); - this.itersectObserver.unobserve(node); + this.lazyTranslator.disable(node); } const nodeData = this.nodeStorage.get(node); From 1fcf93cd67e8048a92f37f5d527ea3902b5da0e8 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 5 Feb 2025 23:21:24 +0100 Subject: [PATCH 002/313] test: add --- src/__tests__/LazyTranslator.test.ts | 121 +++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/__tests__/LazyTranslator.test.ts diff --git a/src/__tests__/LazyTranslator.test.ts b/src/__tests__/LazyTranslator.test.ts new file mode 100644 index 0000000..86f6f96 --- /dev/null +++ b/src/__tests__/LazyTranslator.test.ts @@ -0,0 +1,121 @@ +import { vi } from 'vitest'; + +import { LazyTranslator } from '../LazyTranslator'; + +require('intersection-observer'); + +const delay = (time: number) => new Promise((res) => setTimeout(res, time)); +const awaitTranslation = () => delay(120); + +const TRANSLATION_SYMBOL = '***TRANSLATED***'; + +const escapeRegexString = (input: string) => input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const containsRegex = (input: string) => new RegExp(`${escapeRegexString(input)}`); + +const translator = vi.fn().mockImplementation(async (node: Text) => { + return (node.textContent = TRANSLATION_SYMBOL + node.textContent); +}); + +const isTranslatableNode = (node: Node) => node instanceof Text; + +describe('base usage', () => { + const divElement = document.createElement('div'); + const textNode = document.createTextNode('Hello, World!'); + + divElement.appendChild(textNode); + document.body.appendChild(divElement); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('translate element at intersection', async () => { + const lazyTraslator = new LazyTranslator(translator, { + lazyTranslate: true, + isTranslatableNode, + }); + + const isLazyTranslate = lazyTraslator.process(textNode); + + await awaitTranslation(); + + // The mock function was called ones + expect(translator.mock.calls).toHaveLength(1); + expect(translator).toHaveBeenCalledWith(textNode); + + // the node translate lazy + expect(isLazyTranslate).toBe(true); + expect(textNode.textContent).toMatchObject(containsRegex(TRANSLATION_SYMBOL)); + }); + + test('translate node that intersect the custom ancestor', async () => { + const lazyTraslator = new LazyTranslator( + translator, + { + lazyTranslate: true, + isTranslatableNode, + }, + { root: divElement }, + ); + const isLazyTranslate = lazyTraslator.process(textNode); + + await awaitTranslation(); + + // The mock function was called ones + expect(translator.mock.calls).toHaveLength(1); + expect(translator).toHaveBeenCalledWith(textNode); + + // the node translate lazy + expect(isLazyTranslate).toBe(true); + expect(textNode.textContent).toMatchObject(containsRegex(TRANSLATION_SYMBOL)); + }); + + test('not translate nodes that not intersected', async () => { + const textNode = document.createTextNode('Hello World!'); + + const lazyTraslator = new LazyTranslator(translator, { + lazyTranslate: true, + isTranslatableNode, + }); + + const isLazyTranslate = lazyTraslator.process(textNode); + + await awaitTranslation(); + + // The mock function was not called + expect(translator.mock.calls).toHaveLength(0); + + expect(isLazyTranslate).toBe(false); + }); + + test('not translate nodes with lazyTranslate off', async () => { + const lazyTraslator = new LazyTranslator(translator, { + lazyTranslate: false, + isTranslatableNode, + }); + const isLazyTranslate = lazyTraslator.process(textNode); + + await awaitTranslation(); + + expect(translator.mock.calls).toHaveLength(0); + + expect(isLazyTranslate).toBe(false); + }); + + test('not translate node that not intersect the custom ancestor', async () => { + const lazyTraslator = new LazyTranslator( + translator, + { + lazyTranslate: false, + isTranslatableNode, + }, + { root: divElement }, + ); + const isLazyTranslate = lazyTraslator.process(textNode); + + await awaitTranslation(); + + expect(isLazyTranslate).toBe(false); + }); +}); From bac4b4507d9009268bcbd0d2c1791eeb06f7dce1 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 5 Feb 2025 23:55:29 +0100 Subject: [PATCH 003/313] refactor: extract nodeStorage logic into class --- src/DomTranslationProcessor.ts | 247 ++++++++++++++++++++++++++++++ src/NodesTranslator.ts | 264 +++------------------------------ 2 files changed, 271 insertions(+), 240 deletions(-) create mode 100644 src/DomTranslationProcessor.ts diff --git a/src/DomTranslationProcessor.ts b/src/DomTranslationProcessor.ts new file mode 100644 index 0000000..744f9c6 --- /dev/null +++ b/src/DomTranslationProcessor.ts @@ -0,0 +1,247 @@ +import { LazyTranslator } from './LazyTranslator'; +import { InnerConfig, isInViewport, TranslatorInterface } from './NodesTranslator'; + +interface NodeData { + /** + * Unique node identifier + */ + id: number; + + /** + * Each node update should increase the value + */ + updateId: number; + + /** + * Contains `updateId` value at time when start node translation + */ + translateContext: number; + + /** + * Original node text, before start translation + * Contains `null` for node that not been translated yet + */ + originalText: null | string; + + /** + * Priority to translate node. The bigger the faster will translate + */ + priority: number; +} + +/** + * @param handler if return `false`, loop will stop + */ +const nodeExplore = ( + inputNode: Node, + nodeFilter: number, + includeSelf: boolean, + handler: (value: Node) => void | boolean, +) => { + const walk = document.createTreeWalker(inputNode, nodeFilter, null); + let node = includeSelf ? walk.currentNode : walk.nextNode(); + while (node) { + if (handler(node) === false) { + return; + } + node = walk.nextNode(); + } +}; + +export class DomTranslationProcessor { + private readonly config: InnerConfig; + private lazyTranslator: LazyTranslator; + + private readonly translateCallback: TranslatorInterface; + + private idCounter = 0; + private nodeStorage = new WeakMap(); + + constructor( + config: InnerConfig, + lazyTranslator: LazyTranslator, + translateCallback: TranslatorInterface, + ) { + this.config = config; + this.lazyTranslator = lazyTranslator; + this.translateCallback = translateCallback; + } + + public isNodeStorageHas(node: Node) { + return this.nodeStorage.has(node); + } + + public getNodeData(node: Node) { + const nodeData = this.nodeStorage.get(node); + if (nodeData === undefined) return null; + + const { originalText } = nodeData; + return { originalText }; + } + + public handleNode = (node: Node) => { + if (this.isNodeStorageHas(node)) return; + + // Skip empthy text + if (node.nodeValue === null || node.nodeValue.trim().length == 0) return; + + // Skip not translatable nodes + if (!this.isTranslatableNode(node)) return; + + const priority = this.getNodeScore(node); + + this.nodeStorage.set(node, { + id: this.idCounter++, + updateId: 1, + translateContext: 0, + originalText: null, + priority, + }); + + this.translateNode(node); + }; + + public addNode(node: Node) { + // Add all nodes which element contains (text nodes and attributes of current and inner elements) + if (node instanceof Element) { + this.handleTree(node, (node) => { + if (node instanceof Element) return; + + if (this.isTranslatableNode(node)) { + this.addNode(node); + } + }); + + return; + } + + // Handle text nodes and attributes + + if (this.lazyTranslator.process(node)) { + return; + } + + // Add to storage + this.handleNode(node); + } + + public deleteNode(node: Node, onlyTarget = false) { + if (node instanceof Element) { + // Delete all attributes and inner nodes + if (!onlyTarget) { + this.handleTree(node, (node) => { + this.deleteNode(node, true); + }); + } + + // Unobserve + this.lazyTranslator.disable(node); + } + + const nodeData = this.nodeStorage.get(node); + if (nodeData !== undefined) { + // Restore original text if text been replaced + if (nodeData.originalText !== null) { + node.nodeValue = nodeData.originalText; + } + this.nodeStorage.delete(node); + } + } + + // Updates never be lazy + public updateNode(node: Node) { + const nodeData = this.nodeStorage.get(node); + if (nodeData !== undefined) { + nodeData.updateId++; + this.translateNode(node); + } + } + + /** + * Call only for new and updated nodes + */ + private translateNode(node: Node) { + const nodeData = this.nodeStorage.get(node); + if (nodeData === undefined) { + throw new Error('Node is not register'); + } + + if (node.nodeValue === null) return; + + // Recursion prevention + if (nodeData.updateId <= nodeData.translateContext) { + return; + } + + const nodeId = nodeData.id; + const nodeContext = nodeData.updateId; + return this.translateCallback(node.nodeValue, nodeData.priority).then((text) => { + const actualNodeData = this.nodeStorage.get(node); + if (actualNodeData === undefined || nodeId !== actualNodeData.id) { + return; + } + if (nodeContext !== actualNodeData.updateId) { + return; + } + + // actualNodeData.translateData = text; + actualNodeData.originalText = node.nodeValue !== null ? node.nodeValue : ''; + actualNodeData.translateContext = actualNodeData.updateId + 1; + node.nodeValue = text; + return node; + }); + } + + private isTranslatableNode(targetNode: Node) { + return this.config.isTranslatableNode(targetNode); + } + + /** + * Calculate node priority for translate, the bigger number the importance text + */ + private getNodeScore = (node: Node) => { + let score = 0; + + if (node instanceof Attr) { + score += 1; + const parent = node.ownerElement; + if (parent && isInViewport(parent)) { + // Attribute of visible element is important than text of non-visible element + score += 2; + } + } else if (node instanceof Text) { + score += 2; + const parent = node.parentElement; + if (parent && isInViewport(parent)) { + // Text of visible element is most important node for translation + score += 2; + } + } + + return score; + }; + + /** + * Handle all translatable nodes from element + * Element, Attr, Text + */ + private handleTree(node: Element, callback: (node: Node) => void) { + nodeExplore(node, NodeFilter.SHOW_ALL, true, (node) => { + callback(node); + + if (node instanceof Element) { + // Handle nodes from opened shadow DOM + if (node.shadowRoot !== null) { + for (const child of Array.from(node.shadowRoot.children)) { + this.handleTree(child, callback); + } + } + + // Handle attributes of element + for (const attribute of Object.values(node.attributes)) { + callback(attribute); + } + } + }); + } +} diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index f5f10d8..b9cb9c9 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,54 +1,8 @@ +import { DomTranslationProcessor } from './DomTranslationProcessor'; import { LazyTranslator } from './LazyTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; import { configureTranslatableNodePredicate } from './utils/nodes'; -interface NodeData { - /** - * Unique node identifier - */ - id: number; - - /** - * Each node update should increase the value - */ - updateId: number; - - /** - * Contains `updateId` value at time when start node translation - */ - translateContext: number; - - /** - * Original node text, before start translation - * Contains `null` for node that not been translated yet - */ - originalText: null | string; - - /** - * Priority to translate node. The bigger the faster will translate - */ - priority: number; -} - -/** - * @param handler if return `false`, loop will stop - */ -const nodeExplore = ( - inputNode: Node, - nodeFilter: number, - includeSelf: boolean, - handler: (value: Node) => void | boolean, -) => { - const walk = document.createTreeWalker(inputNode, nodeFilter, null); - let node = includeSelf ? walk.currentNode : walk.nextNode(); - while (node) { - if (handler(node) === false) { - return; - } - node = walk.nextNode(); - } -}; - /** * Check visibility of element in viewport */ @@ -70,8 +24,6 @@ export function isInViewport(element: Element, threshold = 0) { return true; } -type TranslatorInterface = (text: string, priority: number) => Promise; - export interface InnerConfig { isTranslatableNode: (node: Node) => boolean; lazyTranslate: boolean; @@ -82,6 +34,8 @@ export interface Config { lazyTranslate?: boolean; } +export type TranslatorInterface = (text: string, priority: number) => Promise; + // TODO: consider local language definitions (and implement `from`, `to` parameters for translator to specify default or locale languages) // TODO: scan nodes lazy - defer scan to `requestIdleCallback` instead of instant scan // TODO: describe nodes life cycle @@ -90,12 +44,10 @@ export interface Config { * Module for dynamic translate a DOM nodes */ export class NodesTranslator { - private readonly translateCallback: TranslatorInterface; private readonly config: InnerConfig; - private lazyTranslator: LazyTranslator; + private domTranslationProcessor: DomTranslationProcessor; constructor(translateCallback: TranslatorInterface, config?: Config) { - this.translateCallback = translateCallback; this.config = { ...config, isTranslatableNode: @@ -104,7 +56,13 @@ export class NodesTranslator { config?.lazyTranslate !== undefined ? config?.lazyTranslate : true, }; - this.lazyTranslator = new LazyTranslator(this.handleNode, this.config); + this.domTranslationProcessor = new DomTranslationProcessor( + this.config, + new LazyTranslator((node: Node) => { + this.domTranslationProcessor.handleNode(node); + }, this.config), + translateCallback, + ); } private readonly observedNodesStorage = new Map(); @@ -117,10 +75,14 @@ export class NodesTranslator { const observer = new XMutationObserver(); this.observedNodesStorage.set(node, observer); - observer.addHandler('elementAdded', ({ target }) => this.addNode(target)); - observer.addHandler('elementRemoved', ({ target }) => this.deleteNode(target)); + observer.addHandler('elementAdded', ({ target }) => + this.domTranslationProcessor.addNode(target), + ); + observer.addHandler('elementRemoved', ({ target }) => + this.domTranslationProcessor.deleteNode(target), + ); observer.addHandler('characterData', ({ target }) => { - this.updateNode(target); + this.domTranslationProcessor.updateNode(target); }); observer.addHandler('changeAttribute', ({ target, attributeName }) => { if (attributeName === undefined || attributeName === null) return; @@ -131,15 +93,15 @@ export class NodesTranslator { if (attribute === null) return; // NOTE: If need delete untracked nodes, we should keep relates like Element -> attributes - if (!this.nodeStorage.has(attribute)) { - this.addNode(attribute); + if (!this.domTranslationProcessor.isNodeStorageHas(attribute)) { + this.domTranslationProcessor.addNode(attribute); } else { - this.updateNode(attribute); + this.domTranslationProcessor.updateNode(attribute); } }); observer.observe(node); - this.addNode(node); + this.domTranslationProcessor.addNode(node); } public unobserve(node: Element) { @@ -147,190 +109,12 @@ export class NodesTranslator { throw new Error('Node is not under observe'); } - this.deleteNode(node); + this.domTranslationProcessor.deleteNode(node); this.observedNodesStorage.get(node)?.disconnect(); this.observedNodesStorage.delete(node); } public getNodeData(node: Node) { - const nodeData = this.nodeStorage.get(node); - if (nodeData === undefined) return null; - - const { originalText } = nodeData; - return { originalText }; - } - - private idCounter = 0; - private nodeStorage = new WeakMap(); - private handleNode = (node: Node) => { - if (this.nodeStorage.has(node)) return; - - // Skip empthy text - if (node.nodeValue === null || node.nodeValue.trim().length == 0) return; - - // Skip not translatable nodes - if (!this.isTranslatableNode(node)) return; - - const priority = this.getNodeScore(node); - - this.nodeStorage.set(node, { - id: this.idCounter++, - updateId: 1, - translateContext: 0, - originalText: null, - priority, - }); - - this.translateNode(node); - }; - - private addNode(node: Node) { - // Add all nodes which element contains (text nodes and attributes of current and inner elements) - if (node instanceof Element) { - this.handleTree(node, (node) => { - if (node instanceof Element) return; - - if (this.isTranslatableNode(node)) { - this.addNode(node); - } - }); - - return; - } - - // Handle text nodes and attributes - - if (this.lazyTranslator.process(node)) { - return; - } - - // Add to storage - this.handleNode(node); - } - - private deleteNode(node: Node, onlyTarget = false) { - if (node instanceof Element) { - // Delete all attributes and inner nodes - if (!onlyTarget) { - this.handleTree(node, (node) => { - this.deleteNode(node, true); - }); - } - - // Unobserve - this.lazyTranslator.disable(node); - } - - const nodeData = this.nodeStorage.get(node); - if (nodeData !== undefined) { - // Restore original text if text been replaced - if (nodeData.originalText !== null) { - node.nodeValue = nodeData.originalText; - } - this.nodeStorage.delete(node); - } - } - - // Updates never be lazy - private updateNode(node: Node) { - const nodeData = this.nodeStorage.get(node); - if (nodeData !== undefined) { - nodeData.updateId++; - this.translateNode(node); - } - } - - /** - * Call only for new and updated nodes - */ - private translateNode(node: Node) { - const nodeData = this.nodeStorage.get(node); - if (nodeData === undefined) { - throw new Error('Node is not register'); - } - - if (node.nodeValue === null) return; - - // Recursion prevention - if (nodeData.updateId <= nodeData.translateContext) { - return; - } - - const nodeId = nodeData.id; - const nodeContext = nodeData.updateId; - return this.translateCallback(node.nodeValue, nodeData.priority).then((text) => { - const actualNodeData = this.nodeStorage.get(node); - if (actualNodeData === undefined || nodeId !== actualNodeData.id) { - return; - } - if (nodeContext !== actualNodeData.updateId) { - return; - } - - // actualNodeData.translateData = text; - actualNodeData.originalText = node.nodeValue !== null ? node.nodeValue : ''; - actualNodeData.translateContext = actualNodeData.updateId + 1; - node.nodeValue = text; - return node; - }); - } - - private isTranslatableNode(targetNode: Node) { - return this.config.isTranslatableNode(targetNode); - } - - private isIntersectableNode = (node: Element) => { - if (node.nodeName === 'OPTION') return false; - - return document.body.contains(node); - }; - - /** - * Calculate node priority for translate, the bigger number the importance text - */ - private getNodeScore = (node: Node) => { - let score = 0; - - if (node instanceof Attr) { - score += 1; - const parent = node.ownerElement; - if (parent && isInViewport(parent)) { - // Attribute of visible element is important than text of non-visible element - score += 2; - } - } else if (node instanceof Text) { - score += 2; - const parent = node.parentElement; - if (parent && isInViewport(parent)) { - // Text of visible element is most important node for translation - score += 2; - } - } - - return score; - }; - - /** - * Handle all translatable nodes from element - * Element, Attr, Text - */ - private handleTree(node: Element, callback: (node: Node) => void) { - nodeExplore(node, NodeFilter.SHOW_ALL, true, (node) => { - callback(node); - - if (node instanceof Element) { - // Handle nodes from opened shadow DOM - if (node.shadowRoot !== null) { - for (const child of Array.from(node.shadowRoot.children)) { - this.handleTree(child, callback); - } - } - - // Handle attributes of element - for (const attribute of Object.values(node.attributes)) { - callback(attribute); - } - } - }); + return this.domTranslationProcessor.getNodeData(node); } } From 47c13a5ef0a76e2304e4b2f95efc5c88843386e2 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 6 Feb 2025 00:01:08 +0100 Subject: [PATCH 004/313] chore: move function to utils --- src/DomTranslationProcessor.ts | 23 +++-------------------- src/NodesTranslator.ts | 21 --------------------- src/utils/isInViewport.ts | 21 +++++++++++++++++++++ src/utils/nodeExplore.ts | 18 ++++++++++++++++++ 4 files changed, 42 insertions(+), 41 deletions(-) create mode 100644 src/utils/isInViewport.ts create mode 100644 src/utils/nodeExplore.ts diff --git a/src/DomTranslationProcessor.ts b/src/DomTranslationProcessor.ts index 744f9c6..e8101e4 100644 --- a/src/DomTranslationProcessor.ts +++ b/src/DomTranslationProcessor.ts @@ -1,5 +1,7 @@ import { LazyTranslator } from './LazyTranslator'; -import { InnerConfig, isInViewport, TranslatorInterface } from './NodesTranslator'; +import { InnerConfig, TranslatorInterface } from './NodesTranslator'; +import { isInViewport } from './utils/isInViewport'; +import { nodeExplore } from './utils/nodeExplore'; interface NodeData { /** @@ -29,25 +31,6 @@ interface NodeData { priority: number; } -/** - * @param handler if return `false`, loop will stop - */ -const nodeExplore = ( - inputNode: Node, - nodeFilter: number, - includeSelf: boolean, - handler: (value: Node) => void | boolean, -) => { - const walk = document.createTreeWalker(inputNode, nodeFilter, null); - let node = includeSelf ? walk.currentNode : walk.nextNode(); - while (node) { - if (handler(node) === false) { - return; - } - node = walk.nextNode(); - } -}; - export class DomTranslationProcessor { private readonly config: InnerConfig; private lazyTranslator: LazyTranslator; diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index b9cb9c9..b8fef85 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -3,27 +3,6 @@ import { LazyTranslator } from './LazyTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; import { configureTranslatableNodePredicate } from './utils/nodes'; -/** - * Check visibility of element in viewport - */ -export function isInViewport(element: Element, threshold = 0) { - const { top, left, bottom, right, height, width } = element.getBoundingClientRect(); - const overflows = { - top, - left, - bottom: (window.innerHeight || document.documentElement.clientHeight) - bottom, - right: (window.innerWidth || document.documentElement.clientWidth) - right, - }; - - if (overflows.top + height * threshold < 0) return false; - if (overflows.bottom + height * threshold < 0) return false; - - if (overflows.left + width * threshold < 0) return false; - if (overflows.right + width * threshold < 0) return false; - - return true; -} - export interface InnerConfig { isTranslatableNode: (node: Node) => boolean; lazyTranslate: boolean; diff --git a/src/utils/isInViewport.ts b/src/utils/isInViewport.ts new file mode 100644 index 0000000..bea449c --- /dev/null +++ b/src/utils/isInViewport.ts @@ -0,0 +1,21 @@ +/** + * Check visibility of element in viewport + */ + +export function isInViewport(element: Element, threshold = 0) { + const { top, left, bottom, right, height, width } = element.getBoundingClientRect(); + const overflows = { + top, + left, + bottom: (window.innerHeight || document.documentElement.clientHeight) - bottom, + right: (window.innerWidth || document.documentElement.clientWidth) - right, + }; + + if (overflows.top + height * threshold < 0) return false; + if (overflows.bottom + height * threshold < 0) return false; + + if (overflows.left + width * threshold < 0) return false; + if (overflows.right + width * threshold < 0) return false; + + return true; +} diff --git a/src/utils/nodeExplore.ts b/src/utils/nodeExplore.ts new file mode 100644 index 0000000..a9bcddd --- /dev/null +++ b/src/utils/nodeExplore.ts @@ -0,0 +1,18 @@ +/** + * @param handler if return `false`, loop will stop + */ +export const nodeExplore = ( + inputNode: Node, + nodeFilter: number, + includeSelf: boolean, + handler: (value: Node) => void | boolean, +) => { + const walk = document.createTreeWalker(inputNode, nodeFilter, null); + let node = includeSelf ? walk.currentNode : walk.nextNode(); + while (node) { + if (handler(node) === false) { + return; + } + node = walk.nextNode(); + } +}; From bd7adceecf5b5bb50670af3108198de05c176f3c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 6 Feb 2025 01:09:36 +0100 Subject: [PATCH 005/313] test: add DomTranslationProcessor test --- src/__tests__/DomTranslationProcessor.test.ts | 159 ++++++++++++++++++ .../DomTranslationProcessor.test.ts.snap | 155 +++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 src/__tests__/DomTranslationProcessor.test.ts create mode 100644 src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap diff --git a/src/__tests__/DomTranslationProcessor.test.ts b/src/__tests__/DomTranslationProcessor.test.ts new file mode 100644 index 0000000..8248ab6 --- /dev/null +++ b/src/__tests__/DomTranslationProcessor.test.ts @@ -0,0 +1,159 @@ +import { readFileSync } from 'fs'; + +import { DomTranslationProcessor } from '../DomTranslationProcessor'; +import { LazyTranslator } from '../LazyTranslator'; + +require('intersection-observer'); + +(IntersectionObserver.prototype as any).POLL_INTERVAL = 100; + +const delay = (time: number) => new Promise((res) => setTimeout(res, time)); +const awaitTranslation = () => delay(120); + +const composeName = (...args: (string | boolean)[]) => args.filter(Boolean).join(' '); + +const TRANSLATION_SYMBOL = '***TRANSLATED***'; +const translator = async (text: string) => TRANSLATION_SYMBOL + text; + +const escapeRegexString = (input: string) => input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const containsRegex = (input: string) => new RegExp(`${escapeRegexString(input)}`); + +const handelNode = vi.fn(); + +const fillDocument = (text: string) => { + document.write(text); +}; + +const sample = readFileSync(__dirname + '/sample.html', 'utf8'); + +// The mock for LazyTranslate class +vi.mock('../LazyTranslator', async (importActual) => { + return { + ...(await importActual()), + LazyTranslator: vi + .fn() + .mockImplementation( + (config: { + isTranslatableNode: (node: Node) => boolean; + lazyTranslate: boolean; + }) => { + return { + process: vi.fn().mockImplementation((node) => { + if (config.lazyTranslate) { + if (node.nodeName !== 'OPTION') { + setTimeout(() => {}, 3000); + return true; + } + } + return false; + }), + disable: vi.fn(), + }; + }, + ), + }; +}); + +describe('base usage', () => { + [true, false].forEach((lazyTranslate) => { + const testName = composeName( + 'translate whole document and disable translation', + lazyTranslate && 'with lazyTranslate', + ); + + const config = { + lazyTranslate: lazyTranslate, + isTranslatableNode: (node: Node) => node instanceof Text, + }; + + test(testName, async () => { + fillDocument(sample); + + const parsedHTML = document.documentElement.outerHTML; + + const domTranslationProcessor = new DomTranslationProcessor( + config, + new LazyTranslator(handelNode, config), + translator, + ); + + // translate document + domTranslationProcessor.addNode(document.documentElement); + await awaitTranslation(); + expect(document.documentElement.outerHTML).toMatchSnapshot(); + + // disable translation + domTranslationProcessor.deleteNode(document.documentElement); + expect(document.documentElement.outerHTML).toBe(parsedHTML); + }); + + const getNodeDataTestName = composeName( + 'getNodeData returns the original text', + lazyTranslate && 'with lazyTranslate', + ); + + test(getNodeDataTestName, async () => { + const originalText = 'Hello world!'; + + const div0 = document.createElement('div'); + div0.innerHTML = originalText; + + const domTranslationProcessor = new DomTranslationProcessor( + config, + new LazyTranslator(handelNode, config), + translator, + ); + + domTranslationProcessor.addNode(div0); + + await awaitTranslation(); + + expect(domTranslationProcessor.getNodeData(div0.childNodes[0])).toEqual( + expect.objectContaining({ + originalText: originalText, + }), + ); + }); + + const updateNodeTestName = composeName( + 'updateNode should be call ones', + lazyTranslate && 'with lazyTranslate', + ); + + test(updateNodeTestName, async () => { + const div0 = document.createElement('div'); + div0.innerHTML = 'Hello world!'; + document.body.appendChild(div0); + + const domTranslationProcessor = new DomTranslationProcessor( + config, + new LazyTranslator(handelNode, config), + translator, + ); + + // Spy on the updateNode method + const updateNodesSpy = vi.spyOn(domTranslationProcessor, 'updateNode'); + + domTranslationProcessor.addNode(div0); + + await awaitTranslation(); + + // update element + const newText = 'Goodbye world!'; + div0.innerHTML = newText; + domTranslationProcessor.addNode(div0.childNodes[0]); + await awaitTranslation(); + + domTranslationProcessor.updateNode(div0.childNodes[0]); + await awaitTranslation(); + + expect(div0.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); + + expect(updateNodesSpy).toBeCalledTimes(1); + expect(updateNodesSpy.mock.calls[0][0]).toMatchObject( + containsRegex(TRANSLATION_SYMBOL), + ); + }); + }); +}); diff --git a/src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap b/src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap new file mode 100644 index 0000000..d5c33b0 --- /dev/null +++ b/src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap @@ -0,0 +1,155 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`base usage > translate whole document and disable translation 1`] = ` +" + + ***TRANSLATED***Demo page with challenges for DOM translator + + + + + + +***TRANSLATED*** + Text with no container +
***TRANSLATED*** + Text in div
+

***TRANSLATED***Text inside container ***TRANSLATED***link text

+ + image alt text +
***TRANSLATED***Text with preserved formatting
***TRANSLATED*** + + Some code: +
			***TRANSLATED***
+				const name = "Jeff";
+				console.log("Your name is " + name);
+			
+		
+ +
+
+

***TRANSLATED***Words can be like X-rays, if you use them properly—they’ll go through anything. You read and you’re pierced.

+
+
***TRANSLATED***—Aldous Huxley, ***TRANSLATED***Brave New World
+
+ +
+ + + + + + + + + + + +
+ +
+
***TRANSLATED***This text must not be translated ***TRANSLATED***this and ***TRANSLATED***this***TRANSLATED*** text too***TRANSLATED***.
+
***TRANSLATED***This text must not be translated, since it is editable
+ +
***TRANSLATED***Text with preserved formatting
+ +
+
***TRANSLATED***Items must not be translated:
+
***TRANSLATED***Foo
+
***TRANSLATED***Bar ***TRANSLATED***baz
+
+ + + +
+
+ + + + +" +`; + +exports[`base usage > translate whole document and disable translation with lazyTranslate 1`] = ` +" + + ***TRANSLATED***Demo page with challenges for DOM translator + + + + + + +***TRANSLATED*** + Text with no container +
***TRANSLATED*** + Text in div
+

***TRANSLATED***Text inside container ***TRANSLATED***link text

+ + image alt text +
***TRANSLATED***Text with preserved formatting
***TRANSLATED*** + + Some code: +
			***TRANSLATED***
+				const name = "Jeff";
+				console.log("Your name is " + name);
+			
+		
+ +
+
+

***TRANSLATED***Words can be like X-rays, if you use them properly—they’ll go through anything. You read and you’re pierced.

+
+
***TRANSLATED***—Aldous Huxley, ***TRANSLATED***Brave New World
+
+ +
+ + + + + + + + + + + +
+ +
+
***TRANSLATED***This text must not be translated ***TRANSLATED***this and ***TRANSLATED***this***TRANSLATED*** text too***TRANSLATED***.
+
***TRANSLATED***This text must not be translated, since it is editable
+ +
***TRANSLATED***Text with preserved formatting
+ +
+
***TRANSLATED***Items must not be translated:
+
***TRANSLATED***Foo
+
***TRANSLATED***Bar ***TRANSLATED***baz
+
+ + + +
+
+ + + + +" +`; From 0337b58c3d7fc544e8ac67eeb17cf34fd09b7f31 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 6 Feb 2025 01:31:43 +0100 Subject: [PATCH 006/313] test: add intergation test --- ...nslationProcessorWithLazyTranlator.test.ts | 135 +++++++++++++++ ...ionProcessorWithLazyTranlator.test.ts.snap | 155 ++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts create mode 100644 src/__tests__/__snapshots__/DomTranslationProcessorWithLazyTranlator.test.ts.snap diff --git a/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts b/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts new file mode 100644 index 0000000..15ceb12 --- /dev/null +++ b/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts @@ -0,0 +1,135 @@ +import { readFileSync } from 'fs'; + +import { DomTranslationProcessor } from '../DomTranslationProcessor'; +import { LazyTranslator } from '../LazyTranslator'; + +require('intersection-observer'); + +(IntersectionObserver.prototype as any).POLL_INTERVAL = 100; + +const delay = (time: number) => new Promise((res) => setTimeout(res, time)); +const awaitTranslation = () => delay(120); + +const composeName = (...args: (string | boolean)[]) => args.filter(Boolean).join(' '); + +const TRANSLATION_SYMBOL = '***TRANSLATED***'; +const translator = async (text: string) => TRANSLATION_SYMBOL + text; + +const escapeRegexString = (input: string) => input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const containsRegex = (input: string) => new RegExp(`${escapeRegexString(input)}`); + +const fillDocument = (text: string) => { + document.write(text); +}; + +const sample = readFileSync(__dirname + '/sample.html', 'utf8'); + +describe('base usage', () => { + [true, false].forEach((lazyTranslate) => { + const testName = composeName( + 'translate whole document and disable translation', + lazyTranslate && 'with lazyTranslate', + ); + + const config = { + lazyTranslate: lazyTranslate, + isTranslatableNode: (node: Node) => node instanceof Text, + }; + + test(testName, async () => { + fillDocument(sample); + + const parsedHTML = document.documentElement.outerHTML; + + const domTranslationProcessor = new DomTranslationProcessor( + config, + new LazyTranslator( + (node: Node) => domTranslationProcessor.handleNode(node), + config, + ), + translator, + ); + + // translate document + domTranslationProcessor.addNode(document.documentElement); + await awaitTranslation(); + expect(document.documentElement.outerHTML).toMatchSnapshot(); + + // disable translation + domTranslationProcessor.deleteNode(document.documentElement); + expect(document.documentElement.outerHTML).toBe(parsedHTML); + }); + + const getNodeDataTestName = composeName( + 'getNodeData returns the original text', + lazyTranslate && 'with lazyTranslate', + ); + + test(getNodeDataTestName, async () => { + const originalText = 'Hello world!'; + + const div0 = document.createElement('div'); + div0.innerHTML = originalText; + + const domTranslationProcessor = new DomTranslationProcessor( + config, + new LazyTranslator( + (node: Node) => domTranslationProcessor.handleNode(node), + config, + ), + translator, + ); + + domTranslationProcessor.addNode(div0); + + await awaitTranslation(); + + expect(domTranslationProcessor.getNodeData(div0.childNodes[0])).toEqual( + expect.objectContaining({ + originalText: originalText, + }), + ); + }); + + const updateNodeDataTestName = composeName( + 'updateNode should be called ones', + lazyTranslate && 'with lazyTranslate', + ); + + test(updateNodeDataTestName, async () => { + const div0 = document.createElement('div'); + div0.innerHTML = 'Hello world!'; + document.body.appendChild(div0); + + const domTranslationProcessor = new DomTranslationProcessor( + config, + new LazyTranslator( + (node: Node) => domTranslationProcessor.handleNode(node), + config, + ), + translator, + ); + // Spy on the updateNode method + const updateNodesSpy = vi.spyOn(domTranslationProcessor, 'updateNode'); + + domTranslationProcessor.addNode(div0); + await awaitTranslation(); + + // update element + const newText = 'Goodbye world!'; + div0.innerHTML = newText; + domTranslationProcessor.addNode(div0.childNodes[0]); + await awaitTranslation(); + + domTranslationProcessor.updateNode(div0.childNodes[0]); + await awaitTranslation(); + + expect(updateNodesSpy).toBeCalledTimes(1); + expect(div0.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(updateNodesSpy.mock.calls[0][0]).toMatchObject( + containsRegex(TRANSLATION_SYMBOL), + ); + }); + }); +}); diff --git a/src/__tests__/__snapshots__/DomTranslationProcessorWithLazyTranlator.test.ts.snap b/src/__tests__/__snapshots__/DomTranslationProcessorWithLazyTranlator.test.ts.snap new file mode 100644 index 0000000..b23f710 --- /dev/null +++ b/src/__tests__/__snapshots__/DomTranslationProcessorWithLazyTranlator.test.ts.snap @@ -0,0 +1,155 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`base usage > translate whole document and disable translation 1`] = ` +" + + ***TRANSLATED***Demo page with challenges for DOM translator + + + + + + +***TRANSLATED*** + Text with no container +
***TRANSLATED*** + Text in div
+

***TRANSLATED***Text inside container ***TRANSLATED***link text

+ + image alt text +
***TRANSLATED***Text with preserved formatting
***TRANSLATED*** + + Some code: +
			***TRANSLATED***
+				const name = "Jeff";
+				console.log("Your name is " + name);
+			
+		
+ +
+
+

***TRANSLATED***Words can be like X-rays, if you use them properly—they’ll go through anything. You read and you’re pierced.

+
+
***TRANSLATED***—Aldous Huxley, ***TRANSLATED***Brave New World
+
+ +
+ + + + + + + + + + + +
+ +
+
***TRANSLATED***This text must not be translated ***TRANSLATED***this and ***TRANSLATED***this***TRANSLATED*** text too***TRANSLATED***.
+
***TRANSLATED***This text must not be translated, since it is editable
+ +
***TRANSLATED***Text with preserved formatting
+ +
+
***TRANSLATED***Items must not be translated:
+
***TRANSLATED***Foo
+
***TRANSLATED***Bar ***TRANSLATED***baz
+
+ + + +
+
+ + + + +" +`; + +exports[`base usage > translate whole document and disable translation with lazyTranslate 1`] = ` +" + + ***TRANSLATED***Demo page with challenges for DOM translator + + + + + + +***TRANSLATED*** + Text with no container +
***TRANSLATED*** + Text in div
+

***TRANSLATED***Text inside container ***TRANSLATED***link text

+ + image alt text +
***TRANSLATED***Text with preserved formatting
***TRANSLATED*** + + Some code: +
			***TRANSLATED***
+				const name = "Jeff";
+				console.log("Your name is " + name);
+			
+		
+ +
+
+

***TRANSLATED***Words can be like X-rays, if you use them properly—they’ll go through anything. You read and you’re pierced.

+
+
***TRANSLATED***—Aldous Huxley, ***TRANSLATED***Brave New World
+
+ +
+ + + + + + + + + + + +
+ +
+
***TRANSLATED***This text must not be translated ***TRANSLATED***this and ***TRANSLATED***this***TRANSLATED*** text too***TRANSLATED***.
+
***TRANSLATED***This text must not be translated, since it is editable
+ +
***TRANSLATED***Text with preserved formatting
+ +
+
***TRANSLATED***Items must not be translated:
+
***TRANSLATED***Foo
+
***TRANSLATED***Bar ***TRANSLATED***baz
+
+ + + +
+
+ + + + +" +`; From e01fc2b42937d1851d9bcfa333f6a7d96f1c8aa8 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 6 Feb 2025 01:36:27 +0100 Subject: [PATCH 007/313] chore: move reusable entities to utils --- src/__tests__/DomTranslationProcessor.test.ts | 26 +++++++------------ ...nslationProcessorWithLazyTranlator.test.ts | 24 ++++++----------- src/__tests__/LazyTranslator.test.ts | 10 +------ src/__tests__/utils.ts | 17 ++++++++++++ 4 files changed, 35 insertions(+), 42 deletions(-) create mode 100644 src/__tests__/utils.ts diff --git a/src/__tests__/DomTranslationProcessor.test.ts b/src/__tests__/DomTranslationProcessor.test.ts index 8248ab6..613ac9b 100644 --- a/src/__tests__/DomTranslationProcessor.test.ts +++ b/src/__tests__/DomTranslationProcessor.test.ts @@ -2,31 +2,23 @@ import { readFileSync } from 'fs'; import { DomTranslationProcessor } from '../DomTranslationProcessor'; import { LazyTranslator } from '../LazyTranslator'; +import { + awaitTranslation, + composeName, + containsRegex, + fillDocument, + TRANSLATION_SYMBOL, + translator, +} from './utils'; require('intersection-observer'); (IntersectionObserver.prototype as any).POLL_INTERVAL = 100; -const delay = (time: number) => new Promise((res) => setTimeout(res, time)); -const awaitTranslation = () => delay(120); - -const composeName = (...args: (string | boolean)[]) => args.filter(Boolean).join(' '); - -const TRANSLATION_SYMBOL = '***TRANSLATED***'; -const translator = async (text: string) => TRANSLATION_SYMBOL + text; - -const escapeRegexString = (input: string) => input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - -const containsRegex = (input: string) => new RegExp(`${escapeRegexString(input)}`); +const sample = readFileSync(__dirname + '/sample.html', 'utf8'); const handelNode = vi.fn(); -const fillDocument = (text: string) => { - document.write(text); -}; - -const sample = readFileSync(__dirname + '/sample.html', 'utf8'); - // The mock for LazyTranslate class vi.mock('../LazyTranslator', async (importActual) => { return { diff --git a/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts b/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts index 15ceb12..58010cb 100644 --- a/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts +++ b/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts @@ -2,27 +2,19 @@ import { readFileSync } from 'fs'; import { DomTranslationProcessor } from '../DomTranslationProcessor'; import { LazyTranslator } from '../LazyTranslator'; +import { + awaitTranslation, + composeName, + containsRegex, + fillDocument, + TRANSLATION_SYMBOL, + translator, +} from './utils'; require('intersection-observer'); (IntersectionObserver.prototype as any).POLL_INTERVAL = 100; -const delay = (time: number) => new Promise((res) => setTimeout(res, time)); -const awaitTranslation = () => delay(120); - -const composeName = (...args: (string | boolean)[]) => args.filter(Boolean).join(' '); - -const TRANSLATION_SYMBOL = '***TRANSLATED***'; -const translator = async (text: string) => TRANSLATION_SYMBOL + text; - -const escapeRegexString = (input: string) => input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - -const containsRegex = (input: string) => new RegExp(`${escapeRegexString(input)}`); - -const fillDocument = (text: string) => { - document.write(text); -}; - const sample = readFileSync(__dirname + '/sample.html', 'utf8'); describe('base usage', () => { diff --git a/src/__tests__/LazyTranslator.test.ts b/src/__tests__/LazyTranslator.test.ts index 86f6f96..e504038 100644 --- a/src/__tests__/LazyTranslator.test.ts +++ b/src/__tests__/LazyTranslator.test.ts @@ -1,18 +1,10 @@ import { vi } from 'vitest'; import { LazyTranslator } from '../LazyTranslator'; +import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL } from './utils'; require('intersection-observer'); -const delay = (time: number) => new Promise((res) => setTimeout(res, time)); -const awaitTranslation = () => delay(120); - -const TRANSLATION_SYMBOL = '***TRANSLATED***'; - -const escapeRegexString = (input: string) => input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - -const containsRegex = (input: string) => new RegExp(`${escapeRegexString(input)}`); - const translator = vi.fn().mockImplementation(async (node: Text) => { return (node.textContent = TRANSLATION_SYMBOL + node.textContent); }); diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts new file mode 100644 index 0000000..1ef6fbf --- /dev/null +++ b/src/__tests__/utils.ts @@ -0,0 +1,17 @@ +export const delay = (time: number) => new Promise((res) => setTimeout(res, time)); +export const awaitTranslation = () => delay(120); + +export const composeName = (...args: (string | boolean)[]) => + args.filter(Boolean).join(' '); + +export const TRANSLATION_SYMBOL = '***TRANSLATED***'; +export const translator = async (text: string) => TRANSLATION_SYMBOL + text; + +export const escapeRegexString = (input: string) => + input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +export const containsRegex = (input: string) => new RegExp(`${escapeRegexString(input)}`); + +export const fillDocument = (text: string) => { + document.write(text); +}; From f124c7eea8a09ad8393821696fe66086a792b26c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 24 Feb 2025 02:35:00 +0100 Subject: [PATCH 008/313] refactor: add setter for callback --- src/LazyTranslator.ts | 9 ++- src/NodesTranslator.ts | 8 +- src/__tests__/DomTranslationProcessor.test.ts | 77 +++++++++++-------- ...nslationProcessorWithLazyTranlator.test.ts | 68 ++++++++-------- src/__tests__/LazyTranslator.test.ts | 16 ++-- 5 files changed, 99 insertions(+), 79 deletions(-) diff --git a/src/LazyTranslator.ts b/src/LazyTranslator.ts index a6a1cc6..4879134 100644 --- a/src/LazyTranslator.ts +++ b/src/LazyTranslator.ts @@ -20,7 +20,7 @@ function isIntersectableNode(node: Element) { * by default, the top-level document's viewport. */ export class LazyTranslator { - private readonly translator: Translator; + private translator?: Translator; private readonly config: InnerConfig; private readonly itersectStorage = new WeakSet(); @@ -28,7 +28,6 @@ export class LazyTranslator { private itersectObserver: IntersectionObserver; constructor( - translator: Translator, config: InnerConfig, intersectionConfig: IntersectionConfig = { root: null, @@ -36,7 +35,6 @@ export class LazyTranslator { threshold: 0, }, ) { - this.translator = translator; this.config = config; this.itersectObserver = new IntersectionObserver((entries, observer) => { @@ -53,6 +51,10 @@ export class LazyTranslator { }, intersectionConfig); } + public setTranslator(callback: (node: Node) => void) { + this.translator = callback; + } + private handlerIntersectNode(node: Node) { // Translate child text nodes and attributes of target node // WARNING: we shall not touch inner nodes, because its may still not intersected @@ -61,6 +63,7 @@ export class LazyTranslator { return; } + if (!this.translator) throw new Error('expect node handler'); this.translator(node); }); } diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index b8fef85..77c3835 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -25,6 +25,7 @@ export type TranslatorInterface = (text: string, priority: number) => Promise { - this.domTranslationProcessor.handleNode(node); - }, this.config), + this.lazyTranslator, translateCallback, ); + + this.lazyTranslator.setTranslator(this.domTranslationProcessor.handleNode); } private readonly observedNodesStorage = new Map(); diff --git a/src/__tests__/DomTranslationProcessor.test.ts b/src/__tests__/DomTranslationProcessor.test.ts index 613ac9b..7238a9f 100644 --- a/src/__tests__/DomTranslationProcessor.test.ts +++ b/src/__tests__/DomTranslationProcessor.test.ts @@ -30,13 +30,19 @@ vi.mock('../LazyTranslator', async (importActual) => { isTranslatableNode: (node: Node) => boolean; lazyTranslate: boolean; }) => { + let translatorCallback: ((node: Node) => void) | undefined; + return { + setTranslator: vi.fn().mockImplementation((callback) => { + translatorCallback = callback; + }), process: vi.fn().mockImplementation((node) => { if (config.lazyTranslate) { - if (node.nodeName !== 'OPTION') { - setTimeout(() => {}, 3000); - return true; + const isTextNode = node instanceof Text; + if (isTextNode && translatorCallback) { + translatorCallback(node); } + return true; } return false; }), @@ -49,34 +55,46 @@ vi.mock('../LazyTranslator', async (importActual) => { describe('base usage', () => { [true, false].forEach((lazyTranslate) => { + let domTranslationProcessor: DomTranslationProcessor | null; + + beforeEach(() => { + const config = { + lazyTranslate: lazyTranslate, + isTranslatableNode: (node: Node) => node instanceof Text, + }; + + const lazyTranslator = new (vi.mocked(LazyTranslator)!)(config); + + domTranslationProcessor = new DomTranslationProcessor( + config, + lazyTranslator, + translator, + ); + + lazyTranslator.setTranslator(domTranslationProcessor.handleNode); + }); + + afterEach(() => { + domTranslationProcessor = null; + }); + const testName = composeName( 'translate whole document and disable translation', lazyTranslate && 'with lazyTranslate', ); - const config = { - lazyTranslate: lazyTranslate, - isTranslatableNode: (node: Node) => node instanceof Text, - }; - test(testName, async () => { fillDocument(sample); const parsedHTML = document.documentElement.outerHTML; - const domTranslationProcessor = new DomTranslationProcessor( - config, - new LazyTranslator(handelNode, config), - translator, - ); - // translate document - domTranslationProcessor.addNode(document.documentElement); + domTranslationProcessor?.addNode(document.documentElement); await awaitTranslation(); expect(document.documentElement.outerHTML).toMatchSnapshot(); // disable translation - domTranslationProcessor.deleteNode(document.documentElement); + domTranslationProcessor?.deleteNode(document.documentElement); expect(document.documentElement.outerHTML).toBe(parsedHTML); }); @@ -91,17 +109,11 @@ describe('base usage', () => { const div0 = document.createElement('div'); div0.innerHTML = originalText; - const domTranslationProcessor = new DomTranslationProcessor( - config, - new LazyTranslator(handelNode, config), - translator, - ); - - domTranslationProcessor.addNode(div0); + domTranslationProcessor?.addNode(div0); await awaitTranslation(); - expect(domTranslationProcessor.getNodeData(div0.childNodes[0])).toEqual( + expect(domTranslationProcessor?.getNodeData(div0.childNodes[0])).toEqual( expect.objectContaining({ originalText: originalText, }), @@ -118,26 +130,23 @@ describe('base usage', () => { div0.innerHTML = 'Hello world!'; document.body.appendChild(div0); - const domTranslationProcessor = new DomTranslationProcessor( - config, - new LazyTranslator(handelNode, config), - translator, - ); - // Spy on the updateNode method - const updateNodesSpy = vi.spyOn(domTranslationProcessor, 'updateNode'); + const updateNodesSpy = vi.spyOn( + domTranslationProcessor as DomTranslationProcessor, + 'updateNode', + ); - domTranslationProcessor.addNode(div0); + domTranslationProcessor?.addNode(div0); await awaitTranslation(); // update element const newText = 'Goodbye world!'; div0.innerHTML = newText; - domTranslationProcessor.addNode(div0.childNodes[0]); + domTranslationProcessor?.addNode(div0.childNodes[0]); await awaitTranslation(); - domTranslationProcessor.updateNode(div0.childNodes[0]); + domTranslationProcessor?.updateNode(div0.childNodes[0]); await awaitTranslation(); expect(div0.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); diff --git a/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts b/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts index 58010cb..9f39cc7 100644 --- a/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts +++ b/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts @@ -19,6 +19,29 @@ const sample = readFileSync(__dirname + '/sample.html', 'utf8'); describe('base usage', () => { [true, false].forEach((lazyTranslate) => { + let domTranslationProcessor: DomTranslationProcessor | null; + + beforeEach(() => { + const config = { + lazyTranslate: lazyTranslate, + isTranslatableNode: (node: Node) => node instanceof Text, + }; + + const lazyTranslator = new (vi.mocked(LazyTranslator)!)(config); + + domTranslationProcessor = new DomTranslationProcessor( + config, + lazyTranslator, + translator, + ); + + lazyTranslator.setTranslator(domTranslationProcessor.handleNode); + }); + + afterEach(() => { + domTranslationProcessor = null; + }); + const testName = composeName( 'translate whole document and disable translation', lazyTranslate && 'with lazyTranslate', @@ -34,22 +57,13 @@ describe('base usage', () => { const parsedHTML = document.documentElement.outerHTML; - const domTranslationProcessor = new DomTranslationProcessor( - config, - new LazyTranslator( - (node: Node) => domTranslationProcessor.handleNode(node), - config, - ), - translator, - ); - // translate document - domTranslationProcessor.addNode(document.documentElement); + domTranslationProcessor?.addNode(document.documentElement); await awaitTranslation(); expect(document.documentElement.outerHTML).toMatchSnapshot(); // disable translation - domTranslationProcessor.deleteNode(document.documentElement); + domTranslationProcessor?.deleteNode(document.documentElement); expect(document.documentElement.outerHTML).toBe(parsedHTML); }); @@ -64,20 +78,11 @@ describe('base usage', () => { const div0 = document.createElement('div'); div0.innerHTML = originalText; - const domTranslationProcessor = new DomTranslationProcessor( - config, - new LazyTranslator( - (node: Node) => domTranslationProcessor.handleNode(node), - config, - ), - translator, - ); - - domTranslationProcessor.addNode(div0); + domTranslationProcessor?.addNode(div0); await awaitTranslation(); - expect(domTranslationProcessor.getNodeData(div0.childNodes[0])).toEqual( + expect(domTranslationProcessor?.getNodeData(div0.childNodes[0])).toEqual( expect.objectContaining({ originalText: originalText, }), @@ -94,27 +99,22 @@ describe('base usage', () => { div0.innerHTML = 'Hello world!'; document.body.appendChild(div0); - const domTranslationProcessor = new DomTranslationProcessor( - config, - new LazyTranslator( - (node: Node) => domTranslationProcessor.handleNode(node), - config, - ), - translator, - ); // Spy on the updateNode method - const updateNodesSpy = vi.spyOn(domTranslationProcessor, 'updateNode'); + const updateNodesSpy = vi.spyOn( + domTranslationProcessor as DomTranslationProcessor, + 'updateNode', + ); - domTranslationProcessor.addNode(div0); + domTranslationProcessor?.addNode(div0); await awaitTranslation(); // update element const newText = 'Goodbye world!'; div0.innerHTML = newText; - domTranslationProcessor.addNode(div0.childNodes[0]); + domTranslationProcessor?.addNode(div0.childNodes[0]); await awaitTranslation(); - domTranslationProcessor.updateNode(div0.childNodes[0]); + domTranslationProcessor?.updateNode(div0.childNodes[0]); await awaitTranslation(); expect(updateNodesSpy).toBeCalledTimes(1); diff --git a/src/__tests__/LazyTranslator.test.ts b/src/__tests__/LazyTranslator.test.ts index e504038..03ea015 100644 --- a/src/__tests__/LazyTranslator.test.ts +++ b/src/__tests__/LazyTranslator.test.ts @@ -23,10 +23,11 @@ describe('base usage', () => { }); test('translate element at intersection', async () => { - const lazyTraslator = new LazyTranslator(translator, { + const lazyTraslator = new LazyTranslator({ lazyTranslate: true, isTranslatableNode, }); + lazyTraslator.setTranslator(translator); const isLazyTranslate = lazyTraslator.process(textNode); @@ -43,13 +44,14 @@ describe('base usage', () => { test('translate node that intersect the custom ancestor', async () => { const lazyTraslator = new LazyTranslator( - translator, { lazyTranslate: true, isTranslatableNode, }, { root: divElement }, ); + lazyTraslator.setTranslator(translator); + const isLazyTranslate = lazyTraslator.process(textNode); await awaitTranslation(); @@ -66,10 +68,11 @@ describe('base usage', () => { test('not translate nodes that not intersected', async () => { const textNode = document.createTextNode('Hello World!'); - const lazyTraslator = new LazyTranslator(translator, { + const lazyTraslator = new LazyTranslator({ lazyTranslate: true, isTranslatableNode, }); + lazyTraslator.setTranslator(translator); const isLazyTranslate = lazyTraslator.process(textNode); @@ -82,10 +85,12 @@ describe('base usage', () => { }); test('not translate nodes with lazyTranslate off', async () => { - const lazyTraslator = new LazyTranslator(translator, { + const lazyTraslator = new LazyTranslator({ lazyTranslate: false, isTranslatableNode, }); + lazyTraslator.setTranslator(translator); + const isLazyTranslate = lazyTraslator.process(textNode); await awaitTranslation(); @@ -97,13 +102,14 @@ describe('base usage', () => { test('not translate node that not intersect the custom ancestor', async () => { const lazyTraslator = new LazyTranslator( - translator, { lazyTranslate: false, isTranslatableNode, }, { root: divElement }, ); + lazyTraslator.setTranslator(translator); + const isLazyTranslate = lazyTraslator.process(textNode); await awaitTranslation(); From 8ded45935ce8260bf62a4cc993f605bab279b456 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 26 Feb 2025 23:54:39 +0100 Subject: [PATCH 009/313] refactor: move node storage to new class --- src/DomTranslationProcessor.ts | 57 +++------------ src/NodeStorage.ts | 72 +++++++++++++++++++ src/NodesTranslator.ts | 2 + src/__tests__/DomTranslationProcessor.test.ts | 4 +- ...nslationProcessorWithLazyTranlator.test.ts | 2 + 5 files changed, 87 insertions(+), 50 deletions(-) create mode 100644 src/NodeStorage.ts diff --git a/src/DomTranslationProcessor.ts b/src/DomTranslationProcessor.ts index e8101e4..4b231a1 100644 --- a/src/DomTranslationProcessor.ts +++ b/src/DomTranslationProcessor.ts @@ -1,53 +1,27 @@ import { LazyTranslator } from './LazyTranslator'; +import { NodeStorage } from './NodeStorage'; import { InnerConfig, TranslatorInterface } from './NodesTranslator'; import { isInViewport } from './utils/isInViewport'; import { nodeExplore } from './utils/nodeExplore'; -interface NodeData { - /** - * Unique node identifier - */ - id: number; - - /** - * Each node update should increase the value - */ - updateId: number; - - /** - * Contains `updateId` value at time when start node translation - */ - translateContext: number; - - /** - * Original node text, before start translation - * Contains `null` for node that not been translated yet - */ - originalText: null | string; - - /** - * Priority to translate node. The bigger the faster will translate - */ - priority: number; -} - export class DomTranslationProcessor { private readonly config: InnerConfig; private lazyTranslator: LazyTranslator; private readonly translateCallback: TranslatorInterface; - private idCounter = 0; - private nodeStorage = new WeakMap(); + private nodeStorage: NodeStorage; constructor( config: InnerConfig, lazyTranslator: LazyTranslator, translateCallback: TranslatorInterface, + nodeStorage: NodeStorage, ) { this.config = config; this.lazyTranslator = lazyTranslator; this.translateCallback = translateCallback; + this.nodeStorage = nodeStorage; } public isNodeStorageHas(node: Node) { @@ -73,13 +47,7 @@ export class DomTranslationProcessor { const priority = this.getNodeScore(node); - this.nodeStorage.set(node, { - id: this.idCounter++, - updateId: 1, - translateContext: 0, - originalText: null, - priority, - }); + this.nodeStorage.add(node, priority); this.translateNode(node); }; @@ -121,21 +89,14 @@ export class DomTranslationProcessor { this.lazyTranslator.disable(node); } - const nodeData = this.nodeStorage.get(node); - if (nodeData !== undefined) { - // Restore original text if text been replaced - if (nodeData.originalText !== null) { - node.nodeValue = nodeData.originalText; - } - this.nodeStorage.delete(node); - } + this.nodeStorage.delete(node); } // Updates never be lazy public updateNode(node: Node) { - const nodeData = this.nodeStorage.get(node); - if (nodeData !== undefined) { - nodeData.updateId++; + if (this.isNodeStorageHas(node)) { + this.nodeStorage.update(node); + this.translateNode(node); } } diff --git a/src/NodeStorage.ts b/src/NodeStorage.ts new file mode 100644 index 0000000..a4b5c1a --- /dev/null +++ b/src/NodeStorage.ts @@ -0,0 +1,72 @@ +export interface NodeData { + /** + * Unique node identifier + */ + id: number; + + /** + * Each node update should increase the value + */ + updateId: number; + + /** + * Contains `updateId` value at time when start node translation + */ + translateContext: number; + + /** + * Original node text, before start translation + * Contains `null` for node that not been translated yet + */ + originalText: null | string; + + /** + * Priority to translate node. The bigger the faster will translate + */ + priority: number; +} + +export class NodeStorage { + private idCounter = 0; + private nodeStorage = new WeakMap(); + + public has(node: Node) { + return this.nodeStorage.has(node); + } + + public get(node: Node) { + return this.nodeStorage.get(node); + } + + public add(node: Node, priority: number) { + if (this.nodeStorage.has(node)) { + return; + } + + this.nodeStorage.set(node, { + id: this.idCounter++, + updateId: 1, + translateContext: 0, + originalText: null, + priority, + }); + } + + public update(node: Node) { + const nodeData = this.nodeStorage.get(node); + if (nodeData !== undefined) { + nodeData.updateId++; + } + } + + public delete(node: Node) { + const nodeData = this.nodeStorage.get(node); + if (nodeData !== undefined) { + // Restore original text if text been replaced + if (nodeData.originalText !== null) { + node.nodeValue = nodeData.originalText; + } + this.nodeStorage.delete(node); + } + } +} diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 77c3835..bfdea75 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,6 +1,7 @@ import { DomTranslationProcessor } from './DomTranslationProcessor'; import { LazyTranslator } from './LazyTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; +import { NodeStorage } from './NodeStorage'; import { configureTranslatableNodePredicate } from './utils/nodes'; export interface InnerConfig { @@ -41,6 +42,7 @@ export class NodesTranslator { this.config, this.lazyTranslator, translateCallback, + new NodeStorage(), ); this.lazyTranslator.setTranslator(this.domTranslationProcessor.handleNode); diff --git a/src/__tests__/DomTranslationProcessor.test.ts b/src/__tests__/DomTranslationProcessor.test.ts index 7238a9f..0bb682a 100644 --- a/src/__tests__/DomTranslationProcessor.test.ts +++ b/src/__tests__/DomTranslationProcessor.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'fs'; import { DomTranslationProcessor } from '../DomTranslationProcessor'; import { LazyTranslator } from '../LazyTranslator'; +import { NodeStorage } from '../NodeStorage'; import { awaitTranslation, composeName, @@ -17,8 +18,6 @@ require('intersection-observer'); const sample = readFileSync(__dirname + '/sample.html', 'utf8'); -const handelNode = vi.fn(); - // The mock for LazyTranslate class vi.mock('../LazyTranslator', async (importActual) => { return { @@ -69,6 +68,7 @@ describe('base usage', () => { config, lazyTranslator, translator, + new NodeStorage(), ); lazyTranslator.setTranslator(domTranslationProcessor.handleNode); diff --git a/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts b/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts index 9f39cc7..3844720 100644 --- a/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts +++ b/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'fs'; import { DomTranslationProcessor } from '../DomTranslationProcessor'; import { LazyTranslator } from '../LazyTranslator'; +import { NodeStorage } from '../NodeStorage'; import { awaitTranslation, composeName, @@ -33,6 +34,7 @@ describe('base usage', () => { config, lazyTranslator, translator, + new NodeStorage(), ); lazyTranslator.setTranslator(domTranslationProcessor.handleNode); From 11f13a298239162f0f47e5e19295402cb434653e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 26 Feb 2025 23:57:43 +0100 Subject: [PATCH 010/313] test: nodeStarage test --- src/__tests__/NodeStorage.test.ts | 79 +++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/__tests__/NodeStorage.test.ts diff --git a/src/__tests__/NodeStorage.test.ts b/src/__tests__/NodeStorage.test.ts new file mode 100644 index 0000000..53b7651 --- /dev/null +++ b/src/__tests__/NodeStorage.test.ts @@ -0,0 +1,79 @@ +import { NodeStorage } from '../NodeStorage'; + +describe('NodeStorage', () => { + let nodeStorage: NodeStorage; + let div: Node; + let div1: Node; + + beforeEach(() => { + nodeStorage = new NodeStorage(); + + div = document.createElement('div'); + div.textContent = 'Hello world!'; + div1 = document.createElement('div'); + }); + + test('return false and undefined for a node that is not added', () => { + expect(nodeStorage.has(div)).toBe(false); + expect(nodeStorage.get(div)).toBeUndefined(); + }); + + test('add a node to storage', () => { + nodeStorage.add(div, 1); + + expect(nodeStorage.has(div)).toBe(true); + expect(nodeStorage.get(div)).toEqual( + expect.objectContaining({ + id: 0, + originalText: null, + priority: 1, + translateContext: 0, + updateId: 1, + }), + ); + }); + + test('can not add the same node twice', () => { + nodeStorage.add(div, 1); + nodeStorage.add(div, 1); + + expect(nodeStorage.get(div)).toEqual( + expect.objectContaining({ + id: 0, + }), + ); + }); + + test('increase id counter when adding new node', () => { + nodeStorage.add(div, 1); + nodeStorage.add(div1, 1); + + expect(nodeStorage.get(div1)).toEqual( + expect.objectContaining({ + id: 1, + }), + ); + }); + + test('increase updateId when updating a node', () => { + nodeStorage.add(div, 1); + nodeStorage.update(div); + + expect(nodeStorage.get(div)).toEqual( + expect.objectContaining({ + updateId: 2, + }), + ); + }); + + test('remove node from storage', () => { + nodeStorage.add(div, 1); + nodeStorage.delete(div); + + expect(nodeStorage.get(div)).toBeUndefined(); + }); + + test('not throw if deleting a non-existent node', () => { + expect(() => nodeStorage.delete(div)).not.toThrow(); + }); +}); From a6c1574076c228c4e1fa08ddbd11ca8780e4cb56 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 27 Feb 2025 22:58:05 +0100 Subject: [PATCH 011/313] refactor: move translator to new class --- src/DomTranslationProcessor.ts | 96 ++++--------------- src/NodesTranslator.ts | 5 +- src/Translator.ts | 70 ++++++++++++++ src/__tests__/DomTranslationProcessor.test.ts | 5 +- ...nslationProcessorWithLazyTranlator.test.ts | 10 +- 5 files changed, 96 insertions(+), 90 deletions(-) create mode 100644 src/Translator.ts diff --git a/src/DomTranslationProcessor.ts b/src/DomTranslationProcessor.ts index 4b231a1..59b94c1 100644 --- a/src/DomTranslationProcessor.ts +++ b/src/DomTranslationProcessor.ts @@ -1,26 +1,24 @@ import { LazyTranslator } from './LazyTranslator'; import { NodeStorage } from './NodeStorage'; -import { InnerConfig, TranslatorInterface } from './NodesTranslator'; -import { isInViewport } from './utils/isInViewport'; +import { Translator } from './Translator'; import { nodeExplore } from './utils/nodeExplore'; export class DomTranslationProcessor { - private readonly config: InnerConfig; - private lazyTranslator: LazyTranslator; - - private readonly translateCallback: TranslatorInterface; + private isTranslatableNode: (node: Node) => boolean; + private lazyTranslator: LazyTranslator; private nodeStorage: NodeStorage; + private translator: Translator; constructor( - config: InnerConfig, + isTranslatableNode: (node: Node) => boolean, lazyTranslator: LazyTranslator, - translateCallback: TranslatorInterface, nodeStorage: NodeStorage, + translator: Translator, ) { - this.config = config; + this.isTranslatableNode = isTranslatableNode; this.lazyTranslator = lazyTranslator; - this.translateCallback = translateCallback; + this.translator = translator; this.nodeStorage = nodeStorage; } @@ -45,11 +43,15 @@ export class DomTranslationProcessor { // Skip not translatable nodes if (!this.isTranslatableNode(node)) return; - const priority = this.getNodeScore(node); + const priority = this.translator.getNodePriority(node); this.nodeStorage.add(node, priority); - this.translateNode(node); + const nodeData = this.nodeStorage.get(node); + if (nodeData === undefined) { + throw new Error('Node is not register'); + } + this.translator.translateNode(node, nodeData); }; public addNode(node: Node) { @@ -94,77 +96,13 @@ export class DomTranslationProcessor { // Updates never be lazy public updateNode(node: Node) { - if (this.isNodeStorageHas(node)) { - this.nodeStorage.update(node); - - this.translateNode(node); - } - } - - /** - * Call only for new and updated nodes - */ - private translateNode(node: Node) { const nodeData = this.nodeStorage.get(node); - if (nodeData === undefined) { - throw new Error('Node is not register'); - } - - if (node.nodeValue === null) return; - - // Recursion prevention - if (nodeData.updateId <= nodeData.translateContext) { - return; + if (nodeData !== undefined) { + this.nodeStorage.update(node); + this.translator.translateNode(node, nodeData); } - - const nodeId = nodeData.id; - const nodeContext = nodeData.updateId; - return this.translateCallback(node.nodeValue, nodeData.priority).then((text) => { - const actualNodeData = this.nodeStorage.get(node); - if (actualNodeData === undefined || nodeId !== actualNodeData.id) { - return; - } - if (nodeContext !== actualNodeData.updateId) { - return; - } - - // actualNodeData.translateData = text; - actualNodeData.originalText = node.nodeValue !== null ? node.nodeValue : ''; - actualNodeData.translateContext = actualNodeData.updateId + 1; - node.nodeValue = text; - return node; - }); - } - - private isTranslatableNode(targetNode: Node) { - return this.config.isTranslatableNode(targetNode); } - /** - * Calculate node priority for translate, the bigger number the importance text - */ - private getNodeScore = (node: Node) => { - let score = 0; - - if (node instanceof Attr) { - score += 1; - const parent = node.ownerElement; - if (parent && isInViewport(parent)) { - // Attribute of visible element is important than text of non-visible element - score += 2; - } - } else if (node instanceof Text) { - score += 2; - const parent = node.parentElement; - if (parent && isInViewport(parent)) { - // Text of visible element is most important node for translation - score += 2; - } - } - - return score; - }; - /** * Handle all translatable nodes from element * Element, Attr, Text diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index bfdea75..0aa46d6 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -2,6 +2,7 @@ import { DomTranslationProcessor } from './DomTranslationProcessor'; import { LazyTranslator } from './LazyTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; import { NodeStorage } from './NodeStorage'; +import { Translator } from './Translator'; import { configureTranslatableNodePredicate } from './utils/nodes'; export interface InnerConfig { @@ -39,10 +40,10 @@ export class NodesTranslator { this.lazyTranslator = new LazyTranslator(this.config); this.domTranslationProcessor = new DomTranslationProcessor( - this.config, + this.config.isTranslatableNode, this.lazyTranslator, - translateCallback, new NodeStorage(), + new Translator(translateCallback), ); this.lazyTranslator.setTranslator(this.domTranslationProcessor.handleNode); diff --git a/src/Translator.ts b/src/Translator.ts new file mode 100644 index 0000000..0777d07 --- /dev/null +++ b/src/Translator.ts @@ -0,0 +1,70 @@ +import { NodeData } from './NodeStorage'; +import { TranslatorInterface } from './NodesTranslator'; +import { isInViewport } from './utils/isInViewport'; + +/** + * The Translator class defines the translation logic + */ +export class Translator { + private readonly translateCallback: TranslatorInterface; + + constructor(translateCallback: TranslatorInterface) { + this.translateCallback = translateCallback; + } + + /** + * Calculate node priority for translate, the bigger number the importance text + */ + public getNodePriority = (node: Node) => { + let score = 0; + + if (node instanceof Attr) { + score += 1; + const parent = node.ownerElement; + if (parent && isInViewport(parent)) { + // Attribute of visible element is important than text of non-visible element + score += 2; + } + } else if (node instanceof Text) { + score += 2; + const parent = node.parentElement; + if (parent && isInViewport(parent)) { + // Text of visible element is most important node for translation + score += 2; + } + } + + return score; + }; + + /** + * Call only for new and updated nodes + */ + public translateNode(node: Node, nodeData: NodeData) { + if (node.nodeValue === null) return; + + // Recursion prevention + if (nodeData.updateId <= nodeData.translateContext) { + return; + } + + const nodeId = nodeData.id; + const nodeContext = nodeData.updateId; + return this.translateCallback(node.nodeValue, nodeData.priority).then((text) => { + // const actualNodeData = getNodeData(node); + // const nodeData = nodeData; + if (nodeData === undefined || nodeId !== nodeData.id) { + return; + } + if (nodeContext !== nodeData.updateId) { + return; + } + + // actualNodeData.translateData = text; + nodeData.originalText = node.nodeValue !== null ? node.nodeValue : ''; + nodeData.translateContext = nodeData.updateId + 1; + node.nodeValue = text; + return node; + }); + } +} diff --git a/src/__tests__/DomTranslationProcessor.test.ts b/src/__tests__/DomTranslationProcessor.test.ts index 0bb682a..b707c00 100644 --- a/src/__tests__/DomTranslationProcessor.test.ts +++ b/src/__tests__/DomTranslationProcessor.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'fs'; import { DomTranslationProcessor } from '../DomTranslationProcessor'; import { LazyTranslator } from '../LazyTranslator'; import { NodeStorage } from '../NodeStorage'; +import { Translator } from '../Translator'; import { awaitTranslation, composeName, @@ -65,10 +66,10 @@ describe('base usage', () => { const lazyTranslator = new (vi.mocked(LazyTranslator)!)(config); domTranslationProcessor = new DomTranslationProcessor( - config, + config.isTranslatableNode, lazyTranslator, - translator, new NodeStorage(), + new Translator(translator), ); lazyTranslator.setTranslator(domTranslationProcessor.handleNode); diff --git a/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts b/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts index 3844720..ffeaecc 100644 --- a/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts +++ b/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'fs'; import { DomTranslationProcessor } from '../DomTranslationProcessor'; import { LazyTranslator } from '../LazyTranslator'; import { NodeStorage } from '../NodeStorage'; +import { Translator } from '../Translator'; import { awaitTranslation, composeName, @@ -31,10 +32,10 @@ describe('base usage', () => { const lazyTranslator = new (vi.mocked(LazyTranslator)!)(config); domTranslationProcessor = new DomTranslationProcessor( - config, + config.isTranslatableNode, lazyTranslator, - translator, new NodeStorage(), + new Translator(translator), ); lazyTranslator.setTranslator(domTranslationProcessor.handleNode); @@ -49,11 +50,6 @@ describe('base usage', () => { lazyTranslate && 'with lazyTranslate', ); - const config = { - lazyTranslate: lazyTranslate, - isTranslatableNode: (node: Node) => node instanceof Text, - }; - test(testName, async () => { fillDocument(sample); From 90d31d033b7b8709e1dac5feb0d48b91eb814329 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 4 Mar 2025 01:10:17 +0100 Subject: [PATCH 012/313] test: add Translator test --- src/__tests__/Translator.test.ts | 103 +++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/__tests__/Translator.test.ts diff --git a/src/__tests__/Translator.test.ts b/src/__tests__/Translator.test.ts new file mode 100644 index 0000000..15274e2 --- /dev/null +++ b/src/__tests__/Translator.test.ts @@ -0,0 +1,103 @@ +import { Translator } from '../Translator'; +import { containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; + +describe('Translator', () => { + let translate: Translator; + let div: HTMLElement; + let attr: Attr; + + beforeEach(() => { + translate = new Translator(translator); + + div = document.createElement('div'); + attr = document.createAttribute('title'); + attr.value = 'Hello attribute!'; + div.setAttributeNode(attr); + }); + + describe('translate method', () => { + test('successful translate text and attr node', async () => { + div.textContent = 'Hello world!'; + + const textNode = div.firstChild; + const attrNode = div.lastChild; + if (!(textNode instanceof Text) || !(attrNode instanceof Attr)) { + return; + } + + // trasnale text node + await expect( + translate.translateNode(textNode, { + id: 0, + originalText: null, + priority: 1, + translateContext: 0, + updateId: 1, + }), + ).resolves.toMatchObject(containsRegex(TRANSLATION_SYMBOL)); + + // translate attr node + await expect( + translate.translateNode(attr, { + id: 0, + originalText: null, + priority: 1, + translateContext: 0, + updateId: 1, + }), + ).resolves.toMatchObject(containsRegex(TRANSLATION_SYMBOL)); + }); + + test('not translate', () => { + // not translate if translateContext == updateId + // Recursion prevention + + const textNode = div.firstChild; + if (!(textNode instanceof Text)) { + return; + } + + expect( + translate.translateNode(textNode, { + id: 0, + originalText: null, + priority: 1, + translateContext: 1, + updateId: 1, + }), + ).toBe(undefined); + }); + + test('not translate if nodeValue null', async () => { + div.textContent = null; + + // if nodeValue is null not translate + expect( + translate.translateNode(div, { + id: 0, + originalText: null, + priority: 1, + translateContext: 0, + updateId: 1, + }), + ).toBe(undefined); + }); + }); + + describe('getNodePriority method', () => { + test('get currect priority for text node and atribute', () => { + const textNode = div.firstChild; + if (!(textNode instanceof Text)) { + return; + } + + expect(translate.getNodePriority(textNode)).toBe(2); + + expect(translate.getNodePriority(attr)).toBe(2); + }); + + test('priority for not attr and node is 0', () => { + expect(translate.getNodePriority(div)).toBe(0); + }); + }); +}); From f37bc795a0c23e48b7c790813224f616227d1f50 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 5 Mar 2025 18:18:05 +0100 Subject: [PATCH 013/313] test: use origin class instead mock --- src/__tests__/DomTranslationProcessor.test.ts | 36 +------------------ .../DomTranslationProcessor.test.ts.snap | 2 +- 2 files changed, 2 insertions(+), 36 deletions(-) diff --git a/src/__tests__/DomTranslationProcessor.test.ts b/src/__tests__/DomTranslationProcessor.test.ts index b707c00..597f2aa 100644 --- a/src/__tests__/DomTranslationProcessor.test.ts +++ b/src/__tests__/DomTranslationProcessor.test.ts @@ -19,40 +19,6 @@ require('intersection-observer'); const sample = readFileSync(__dirname + '/sample.html', 'utf8'); -// The mock for LazyTranslate class -vi.mock('../LazyTranslator', async (importActual) => { - return { - ...(await importActual()), - LazyTranslator: vi - .fn() - .mockImplementation( - (config: { - isTranslatableNode: (node: Node) => boolean; - lazyTranslate: boolean; - }) => { - let translatorCallback: ((node: Node) => void) | undefined; - - return { - setTranslator: vi.fn().mockImplementation((callback) => { - translatorCallback = callback; - }), - process: vi.fn().mockImplementation((node) => { - if (config.lazyTranslate) { - const isTextNode = node instanceof Text; - if (isTextNode && translatorCallback) { - translatorCallback(node); - } - return true; - } - return false; - }), - disable: vi.fn(), - }; - }, - ), - }; -}); - describe('base usage', () => { [true, false].forEach((lazyTranslate) => { let domTranslationProcessor: DomTranslationProcessor | null; @@ -63,7 +29,7 @@ describe('base usage', () => { isTranslatableNode: (node: Node) => node instanceof Text, }; - const lazyTranslator = new (vi.mocked(LazyTranslator)!)(config); + const lazyTranslator = new LazyTranslator(config); domTranslationProcessor = new DomTranslationProcessor( config.isTranslatableNode, diff --git a/src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap b/src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap index d5c33b0..b23f710 100644 --- a/src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap +++ b/src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap @@ -147,7 +147,7 @@ exports[`base usage > translate whole document and disable translation with lazy - + From df2316a7ed65746a11479a4754a6edb75ee58323 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 5 Mar 2025 18:18:13 +0100 Subject: [PATCH 014/313] test: remove unnessary test --- ...nslationProcessorWithLazyTranlator.test.ts | 125 -------------- ...ionProcessorWithLazyTranlator.test.ts.snap | 155 ------------------ 2 files changed, 280 deletions(-) delete mode 100644 src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts delete mode 100644 src/__tests__/__snapshots__/DomTranslationProcessorWithLazyTranlator.test.ts.snap diff --git a/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts b/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts deleted file mode 100644 index ffeaecc..0000000 --- a/src/__tests__/DomTranslationProcessorWithLazyTranlator.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { readFileSync } from 'fs'; - -import { DomTranslationProcessor } from '../DomTranslationProcessor'; -import { LazyTranslator } from '../LazyTranslator'; -import { NodeStorage } from '../NodeStorage'; -import { Translator } from '../Translator'; -import { - awaitTranslation, - composeName, - containsRegex, - fillDocument, - TRANSLATION_SYMBOL, - translator, -} from './utils'; - -require('intersection-observer'); - -(IntersectionObserver.prototype as any).POLL_INTERVAL = 100; - -const sample = readFileSync(__dirname + '/sample.html', 'utf8'); - -describe('base usage', () => { - [true, false].forEach((lazyTranslate) => { - let domTranslationProcessor: DomTranslationProcessor | null; - - beforeEach(() => { - const config = { - lazyTranslate: lazyTranslate, - isTranslatableNode: (node: Node) => node instanceof Text, - }; - - const lazyTranslator = new (vi.mocked(LazyTranslator)!)(config); - - domTranslationProcessor = new DomTranslationProcessor( - config.isTranslatableNode, - lazyTranslator, - new NodeStorage(), - new Translator(translator), - ); - - lazyTranslator.setTranslator(domTranslationProcessor.handleNode); - }); - - afterEach(() => { - domTranslationProcessor = null; - }); - - const testName = composeName( - 'translate whole document and disable translation', - lazyTranslate && 'with lazyTranslate', - ); - - test(testName, async () => { - fillDocument(sample); - - const parsedHTML = document.documentElement.outerHTML; - - // translate document - domTranslationProcessor?.addNode(document.documentElement); - await awaitTranslation(); - expect(document.documentElement.outerHTML).toMatchSnapshot(); - - // disable translation - domTranslationProcessor?.deleteNode(document.documentElement); - expect(document.documentElement.outerHTML).toBe(parsedHTML); - }); - - const getNodeDataTestName = composeName( - 'getNodeData returns the original text', - lazyTranslate && 'with lazyTranslate', - ); - - test(getNodeDataTestName, async () => { - const originalText = 'Hello world!'; - - const div0 = document.createElement('div'); - div0.innerHTML = originalText; - - domTranslationProcessor?.addNode(div0); - - await awaitTranslation(); - - expect(domTranslationProcessor?.getNodeData(div0.childNodes[0])).toEqual( - expect.objectContaining({ - originalText: originalText, - }), - ); - }); - - const updateNodeDataTestName = composeName( - 'updateNode should be called ones', - lazyTranslate && 'with lazyTranslate', - ); - - test(updateNodeDataTestName, async () => { - const div0 = document.createElement('div'); - div0.innerHTML = 'Hello world!'; - document.body.appendChild(div0); - - // Spy on the updateNode method - const updateNodesSpy = vi.spyOn( - domTranslationProcessor as DomTranslationProcessor, - 'updateNode', - ); - - domTranslationProcessor?.addNode(div0); - await awaitTranslation(); - - // update element - const newText = 'Goodbye world!'; - div0.innerHTML = newText; - domTranslationProcessor?.addNode(div0.childNodes[0]); - await awaitTranslation(); - - domTranslationProcessor?.updateNode(div0.childNodes[0]); - await awaitTranslation(); - - expect(updateNodesSpy).toBeCalledTimes(1); - expect(div0.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(updateNodesSpy.mock.calls[0][0]).toMatchObject( - containsRegex(TRANSLATION_SYMBOL), - ); - }); - }); -}); diff --git a/src/__tests__/__snapshots__/DomTranslationProcessorWithLazyTranlator.test.ts.snap b/src/__tests__/__snapshots__/DomTranslationProcessorWithLazyTranlator.test.ts.snap deleted file mode 100644 index b23f710..0000000 --- a/src/__tests__/__snapshots__/DomTranslationProcessorWithLazyTranlator.test.ts.snap +++ /dev/null @@ -1,155 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`base usage > translate whole document and disable translation 1`] = ` -" - - ***TRANSLATED***Demo page with challenges for DOM translator - - - - - - -***TRANSLATED*** - Text with no container -
***TRANSLATED*** - Text in div
-

***TRANSLATED***Text inside container ***TRANSLATED***link text

- - image alt text -
***TRANSLATED***Text with preserved formatting
***TRANSLATED*** - - Some code: -
			***TRANSLATED***
-				const name = "Jeff";
-				console.log("Your name is " + name);
-			
-		
- -
-
-

***TRANSLATED***Words can be like X-rays, if you use them properly—they’ll go through anything. You read and you’re pierced.

-
-
***TRANSLATED***—Aldous Huxley, ***TRANSLATED***Brave New World
-
- -
- - - - - - - - - - - -
- -
-
***TRANSLATED***This text must not be translated ***TRANSLATED***this and ***TRANSLATED***this***TRANSLATED*** text too***TRANSLATED***.
-
***TRANSLATED***This text must not be translated, since it is editable
- -
***TRANSLATED***Text with preserved formatting
- -
-
***TRANSLATED***Items must not be translated:
-
***TRANSLATED***Foo
-
***TRANSLATED***Bar ***TRANSLATED***baz
-
- - - -
-
- - - - -" -`; - -exports[`base usage > translate whole document and disable translation with lazyTranslate 1`] = ` -" - - ***TRANSLATED***Demo page with challenges for DOM translator - - - - - - -***TRANSLATED*** - Text with no container -
***TRANSLATED*** - Text in div
-

***TRANSLATED***Text inside container ***TRANSLATED***link text

- - image alt text -
***TRANSLATED***Text with preserved formatting
***TRANSLATED*** - - Some code: -
			***TRANSLATED***
-				const name = "Jeff";
-				console.log("Your name is " + name);
-			
-		
- -
-
-

***TRANSLATED***Words can be like X-rays, if you use them properly—they’ll go through anything. You read and you’re pierced.

-
-
***TRANSLATED***—Aldous Huxley, ***TRANSLATED***Brave New World
-
- -
- - - - - - - - - - - -
- -
-
***TRANSLATED***This text must not be translated ***TRANSLATED***this and ***TRANSLATED***this***TRANSLATED*** text too***TRANSLATED***.
-
***TRANSLATED***This text must not be translated, since it is editable
- -
***TRANSLATED***Text with preserved formatting
- -
-
***TRANSLATED***Items must not be translated:
-
***TRANSLATED***Foo
-
***TRANSLATED***Bar ***TRANSLATED***baz
-
- - - -
-
- - - - -" -`; From fb756002bab060ac680c50a2091c3979a98fec1f Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 19 Mar 2025 00:46:33 +0100 Subject: [PATCH 015/313] refactor: improve class design --- src/LazyTranslator.ts | 49 ++++++++++------------- src/NodesTranslator.ts | 49 +++++++++++++++++------ src/__tests__/LazyTranslator.test.ts | 60 +++++++--------------------- 3 files changed, 73 insertions(+), 85 deletions(-) diff --git a/src/LazyTranslator.ts b/src/LazyTranslator.ts index 4879134..23b1e21 100644 --- a/src/LazyTranslator.ts +++ b/src/LazyTranslator.ts @@ -1,5 +1,3 @@ -import { InnerConfig } from '.'; - type Translator = (node: Node) => void; type IntersectionConfig = { @@ -21,21 +19,24 @@ function isIntersectableNode(node: Element) { */ export class LazyTranslator { private translator?: Translator; - private readonly config: InnerConfig; + private readonly isTranslatableNode: (node: Node) => boolean; private readonly itersectStorage = new WeakSet(); private itersectObserver: IntersectionObserver; constructor( - config: InnerConfig, + isTranslatableNode: (node: Node) => boolean, + transaltor: Translator, intersectionConfig: IntersectionConfig = { root: null, rootMargin: '0px', threshold: 0, }, ) { - this.config = config; + this.isTranslatableNode = isTranslatableNode; + + this.translator = transaltor; this.itersectObserver = new IntersectionObserver((entries, observer) => { entries.forEach((entry) => { @@ -51,15 +52,11 @@ export class LazyTranslator { }, intersectionConfig); } - public setTranslator(callback: (node: Node) => void) { - this.translator = callback; - } - private handlerIntersectNode(node: Node) { // Translate child text nodes and attributes of target node // WARNING: we shall not touch inner nodes, because its may still not intersected node.childNodes.forEach((node) => { - if (node instanceof Element || !this.config.isTranslatableNode(node)) { + if (node instanceof Element || !this.isTranslatableNode(node)) { return; } @@ -76,33 +73,31 @@ export class LazyTranslator { } /** - * The lazyTranslationHandler method decides whether the node should be processed immediately or later + * The process method determines whether the node can be processed later; otherwise, processes it immediately */ - // - public process(node: Node) { + + public handleNode(node: Node) { // Lazy translate when own element intersect viewport // But translate at once if node have not parent (virtual node) or parent node is outside of body (utility tags like meta or title) - if (this.config.lazyTranslate) { - const isAttachedToDOM = node.getRootNode() !== node; - const observableNode = - node instanceof Attr ? node.ownerElement : node.parentElement; + const isAttachedToDOM = node.getRootNode() !== node; + const observableNode = + node instanceof Attr ? node.ownerElement : node.parentElement; - // Ignore lazy translation for not intersectable nodes and translate it immediately - if ( - isAttachedToDOM && - observableNode !== null && - isIntersectableNode(observableNode) - ) { - this.handleElementByIntersectViewport(observableNode); + // Ignore lazy translation for not intersectable nodes and translate it immediately + if ( + isAttachedToDOM && + observableNode !== null && + isIntersectableNode(observableNode) + ) { + this.handleElementByIntersectViewport(observableNode); - return true; - } + return true; } return false; } - public disable(node: Element) { + public stopHandling(node: Element) { this.itersectStorage.delete(node); this.itersectObserver.unobserve(node); diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 0aa46d6..084b91a 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -37,16 +37,45 @@ export class NodesTranslator { lazyTranslate: config?.lazyTranslate !== undefined ? config?.lazyTranslate : true, }; - this.lazyTranslator = new LazyTranslator(this.config); this.domTranslationProcessor = new DomTranslationProcessor( this.config.isTranslatableNode, - this.lazyTranslator, new NodeStorage(), new Translator(translateCallback), + translateCallback, ); - this.lazyTranslator.setTranslator(this.domTranslationProcessor.handleNode); + this.lazyTranslator = new LazyTranslator( + this.config.isTranslatableNode, + this.domTranslationProcessor.handleNode, + ); + } + + private addNode(node: Node) { + // handle all nodes contained within the element (text nodes and attributes of the current and nested elements) + + if (node instanceof Element) { + this.domTranslationProcessor.processElementChildNodes(node, (node) => { + this.addNode(node); + }); + return; + } + + // if an element can't be translated later, translate it immediately + + if (this.config.lazyTranslate && this.lazyTranslator.handleNode(node)) { + return; + } + + this.domTranslationProcessor.handleNode(node); + } + + private deleteNode(node: Node) { + this.domTranslationProcessor.deleteNode(node); + + if (node instanceof Element) { + this.lazyTranslator.stopHandling(node); + } } private readonly observedNodesStorage = new Map(); @@ -59,12 +88,8 @@ export class NodesTranslator { const observer = new XMutationObserver(); this.observedNodesStorage.set(node, observer); - observer.addHandler('elementAdded', ({ target }) => - this.domTranslationProcessor.addNode(target), - ); - observer.addHandler('elementRemoved', ({ target }) => - this.domTranslationProcessor.deleteNode(target), - ); + observer.addHandler('elementAdded', ({ target }) => this.addNode(target)); + observer.addHandler('elementRemoved', ({ target }) => this.deleteNode(target)); observer.addHandler('characterData', ({ target }) => { this.domTranslationProcessor.updateNode(target); }); @@ -78,14 +103,14 @@ export class NodesTranslator { // NOTE: If need delete untracked nodes, we should keep relates like Element -> attributes if (!this.domTranslationProcessor.isNodeStorageHas(attribute)) { - this.domTranslationProcessor.addNode(attribute); + this.addNode(attribute); } else { this.domTranslationProcessor.updateNode(attribute); } }); observer.observe(node); - this.domTranslationProcessor.addNode(node); + this.addNode(node); } public unobserve(node: Element) { @@ -93,7 +118,7 @@ export class NodesTranslator { throw new Error('Node is not under observe'); } - this.domTranslationProcessor.deleteNode(node); + this.deleteNode(node); this.observedNodesStorage.get(node)?.disconnect(); this.observedNodesStorage.delete(node); } diff --git a/src/__tests__/LazyTranslator.test.ts b/src/__tests__/LazyTranslator.test.ts index 03ea015..d4077f9 100644 --- a/src/__tests__/LazyTranslator.test.ts +++ b/src/__tests__/LazyTranslator.test.ts @@ -23,13 +23,9 @@ describe('base usage', () => { }); test('translate element at intersection', async () => { - const lazyTraslator = new LazyTranslator({ - lazyTranslate: true, - isTranslatableNode, - }); - lazyTraslator.setTranslator(translator); + const lazyTraslator = new LazyTranslator(isTranslatableNode, translator); - const isLazyTranslate = lazyTraslator.process(textNode); + const isLazyTranslate = lazyTraslator.handleNode(textNode); await awaitTranslation(); @@ -43,16 +39,11 @@ describe('base usage', () => { }); test('translate node that intersect the custom ancestor', async () => { - const lazyTraslator = new LazyTranslator( - { - lazyTranslate: true, - isTranslatableNode, - }, - { root: divElement }, - ); - lazyTraslator.setTranslator(translator); + const lazyTraslator = new LazyTranslator(isTranslatableNode, translator, { + root: divElement, + }); - const isLazyTranslate = lazyTraslator.process(textNode); + const isLazyTranslate = lazyTraslator.handleNode(textNode); await awaitTranslation(); @@ -68,13 +59,9 @@ describe('base usage', () => { test('not translate nodes that not intersected', async () => { const textNode = document.createTextNode('Hello World!'); - const lazyTraslator = new LazyTranslator({ - lazyTranslate: true, - isTranslatableNode, - }); - lazyTraslator.setTranslator(translator); + const lazyTraslator = new LazyTranslator(isTranslatableNode, translator); - const isLazyTranslate = lazyTraslator.process(textNode); + const isLazyTranslate = lazyTraslator.handleNode(textNode); await awaitTranslation(); @@ -84,33 +71,14 @@ describe('base usage', () => { expect(isLazyTranslate).toBe(false); }); - test('not translate nodes with lazyTranslate off', async () => { - const lazyTraslator = new LazyTranslator({ - lazyTranslate: false, - isTranslatableNode, - }); - lazyTraslator.setTranslator(translator); - - const isLazyTranslate = lazyTraslator.process(textNode); - - await awaitTranslation(); - - expect(translator.mock.calls).toHaveLength(0); + test('not translate node that not intersect the custom ancestor', async () => { + const textNode = document.createTextNode('Hello World!'); - expect(isLazyTranslate).toBe(false); - }); + const lazyTraslator = new LazyTranslator(isTranslatableNode, translator, { + root: divElement, + }); - test('not translate node that not intersect the custom ancestor', async () => { - const lazyTraslator = new LazyTranslator( - { - lazyTranslate: false, - isTranslatableNode, - }, - { root: divElement }, - ); - lazyTraslator.setTranslator(translator); - - const isLazyTranslate = lazyTraslator.process(textNode); + const isLazyTranslate = lazyTraslator.handleNode(textNode); await awaitTranslation(); From b58faffc17676fc8f3878c51b216b812c4c7e411 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 19 Mar 2025 01:17:21 +0100 Subject: [PATCH 016/313] refactor: remove class coupling, improve desing --- src/DomTranslationProcessor.ts | 48 ++--- src/NodesTranslator.ts | 5 +- src/__tests__/DomTranslationProcessor.test.ts | 171 +++++++++--------- .../DomTranslationProcessor.test.ts.snap | 79 +------- 4 files changed, 104 insertions(+), 199 deletions(-) diff --git a/src/DomTranslationProcessor.ts b/src/DomTranslationProcessor.ts index 59b94c1..440b86a 100644 --- a/src/DomTranslationProcessor.ts +++ b/src/DomTranslationProcessor.ts @@ -1,32 +1,31 @@ -import { LazyTranslator } from './LazyTranslator'; import { NodeStorage } from './NodeStorage'; import { Translator } from './Translator'; import { nodeExplore } from './utils/nodeExplore'; +type IsTranslatableNode = (node: Node) => boolean; + export class DomTranslationProcessor { - private isTranslatableNode: (node: Node) => boolean; + private isTranslatableNode: IsTranslatableNode; - private lazyTranslator: LazyTranslator; private nodeStorage: NodeStorage; + private translator: Translator; constructor( - isTranslatableNode: (node: Node) => boolean, - lazyTranslator: LazyTranslator, + isTranslatableNode: IsTranslatableNode, nodeStorage: NodeStorage, translator: Translator, ) { this.isTranslatableNode = isTranslatableNode; - this.lazyTranslator = lazyTranslator; - this.translator = translator; this.nodeStorage = nodeStorage; + this.translator = translator; } public isNodeStorageHas(node: Node) { return this.nodeStorage.has(node); } - public getNodeData(node: Node) { + public getOriginalNodeText(node: Node) { const nodeData = this.nodeStorage.get(node); if (nodeData === undefined) return null; @@ -54,28 +53,14 @@ export class DomTranslationProcessor { this.translator.translateNode(node, nodeData); }; - public addNode(node: Node) { - // Add all nodes which element contains (text nodes and attributes of current and inner elements) - if (node instanceof Element) { - this.handleTree(node, (node) => { - if (node instanceof Element) return; - - if (this.isTranslatableNode(node)) { - this.addNode(node); - } - }); - - return; - } + public processNodesInElement(element: Element, callback: (node: Node) => void) { + this.handleTree(element, (node) => { + if (node instanceof Element) return; - // Handle text nodes and attributes - - if (this.lazyTranslator.process(node)) { - return; - } - - // Add to storage - this.handleNode(node); + if (this.isTranslatableNode(node)) { + callback(node); + } + }); } public deleteNode(node: Node, onlyTarget = false) { @@ -86,9 +71,6 @@ export class DomTranslationProcessor { this.deleteNode(node, true); }); } - - // Unobserve - this.lazyTranslator.disable(node); } this.nodeStorage.delete(node); @@ -97,8 +79,10 @@ export class DomTranslationProcessor { // Updates never be lazy public updateNode(node: Node) { const nodeData = this.nodeStorage.get(node); + if (nodeData !== undefined) { this.nodeStorage.update(node); + this.translator.translateNode(node, nodeData); } } diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 084b91a..2687d0c 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -42,7 +42,6 @@ export class NodesTranslator { this.config.isTranslatableNode, new NodeStorage(), new Translator(translateCallback), - translateCallback, ); this.lazyTranslator = new LazyTranslator( @@ -55,7 +54,7 @@ export class NodesTranslator { // handle all nodes contained within the element (text nodes and attributes of the current and nested elements) if (node instanceof Element) { - this.domTranslationProcessor.processElementChildNodes(node, (node) => { + this.domTranslationProcessor.processNodesInElement(node, (node) => { this.addNode(node); }); return; @@ -124,6 +123,6 @@ export class NodesTranslator { } public getNodeData(node: Node) { - return this.domTranslationProcessor.getNodeData(node); + return this.domTranslationProcessor.getOriginalNodeText(node); } } diff --git a/src/__tests__/DomTranslationProcessor.test.ts b/src/__tests__/DomTranslationProcessor.test.ts index 597f2aa..6cf20f8 100644 --- a/src/__tests__/DomTranslationProcessor.test.ts +++ b/src/__tests__/DomTranslationProcessor.test.ts @@ -1,127 +1,126 @@ import { readFileSync } from 'fs'; import { DomTranslationProcessor } from '../DomTranslationProcessor'; -import { LazyTranslator } from '../LazyTranslator'; import { NodeStorage } from '../NodeStorage'; import { Translator } from '../Translator'; import { awaitTranslation, - composeName, containsRegex, fillDocument, TRANSLATION_SYMBOL, translator, } from './utils'; -require('intersection-observer'); - -(IntersectionObserver.prototype as any).POLL_INTERVAL = 100; - const sample = readFileSync(__dirname + '/sample.html', 'utf8'); describe('base usage', () => { - [true, false].forEach((lazyTranslate) => { - let domTranslationProcessor: DomTranslationProcessor | null; - - beforeEach(() => { - const config = { - lazyTranslate: lazyTranslate, - isTranslatableNode: (node: Node) => node instanceof Text, - }; - - const lazyTranslator = new LazyTranslator(config); - - domTranslationProcessor = new DomTranslationProcessor( - config.isTranslatableNode, - lazyTranslator, - new NodeStorage(), - new Translator(translator), - ); + let domTranslationProcessor: DomTranslationProcessor | null; - lazyTranslator.setTranslator(domTranslationProcessor.handleNode); - }); + beforeEach(() => { + const isTranslatableNode = (node: Node) => node instanceof Text; - afterEach(() => { - domTranslationProcessor = null; - }); - - const testName = composeName( - 'translate whole document and disable translation', - lazyTranslate && 'with lazyTranslate', + domTranslationProcessor = new DomTranslationProcessor( + isTranslatableNode, + new NodeStorage(), + new Translator(translator), ); + }); - test(testName, async () => { - fillDocument(sample); + afterEach(() => { + domTranslationProcessor = null; + }); - const parsedHTML = document.documentElement.outerHTML; + test('transalate whole document', async () => { + fillDocument(sample); - // translate document - domTranslationProcessor?.addNode(document.documentElement); - await awaitTranslation(); - expect(document.documentElement.outerHTML).toMatchSnapshot(); + const parsedHTML = document.documentElement.outerHTML; - // disable translation - domTranslationProcessor?.deleteNode(document.documentElement); - expect(document.documentElement.outerHTML).toBe(parsedHTML); - }); + // handle all translatable nodes from element - const getNodeDataTestName = composeName( - 'getNodeData returns the original text', - lazyTranslate && 'with lazyTranslate', - ); + if (document.documentElement instanceof Element) { + domTranslationProcessor?.processNodesInElement( + document.documentElement, + (node) => { + domTranslationProcessor?.handleNode(node); + }, + ); + } - test(getNodeDataTestName, async () => { - const originalText = 'Hello world!'; + // translate document - const div0 = document.createElement('div'); - div0.innerHTML = originalText; + domTranslationProcessor?.handleNode(document.documentElement); + await awaitTranslation(); + expect(document.documentElement.outerHTML).toMatchSnapshot(); - domTranslationProcessor?.addNode(div0); + // disable translation - await awaitTranslation(); + domTranslationProcessor?.deleteNode(document.documentElement); + expect(document.documentElement.outerHTML).toBe(parsedHTML); + }); - expect(domTranslationProcessor?.getNodeData(div0.childNodes[0])).toEqual( - expect.objectContaining({ - originalText: originalText, - }), - ); - }); + test('getNodeData returns the original text', async () => { + const originalText = 'Hello world!'; + + const div0 = document.createElement('div'); + div0.innerHTML = originalText; - const updateNodeTestName = composeName( - 'updateNode should be call ones', - lazyTranslate && 'with lazyTranslate', + // handle all translatable nodes from element + + if (div0 instanceof Element) { + domTranslationProcessor?.processNodesInElement(div0, (node) => { + domTranslationProcessor?.handleNode(node); + }); + } + + // translate document + + domTranslationProcessor?.handleNode(div0); + await awaitTranslation(); + + expect(domTranslationProcessor?.getOriginalNodeText(div0.childNodes[0])).toEqual( + expect.objectContaining({ + originalText: originalText, + }), ); + }); - test(updateNodeTestName, async () => { - const div0 = document.createElement('div'); - div0.innerHTML = 'Hello world!'; - document.body.appendChild(div0); + test('updateNode should be call ones', async () => { + const div0 = document.createElement('div'); + div0.innerHTML = 'Hello world!'; + document.body.appendChild(div0); - // Spy on the updateNode method - const updateNodesSpy = vi.spyOn( - domTranslationProcessor as DomTranslationProcessor, - 'updateNode', - ); + // Spy on the updateNode method + const updateNodesSpy = vi.spyOn( + domTranslationProcessor as DomTranslationProcessor, + 'updateNode', + ); - domTranslationProcessor?.addNode(div0); + // handle all translatable nodes from element - await awaitTranslation(); + if (div0 instanceof Element) { + domTranslationProcessor?.processNodesInElement(div0, (node) => { + domTranslationProcessor?.handleNode(node); + }); + } - // update element - const newText = 'Goodbye world!'; - div0.innerHTML = newText; - domTranslationProcessor?.addNode(div0.childNodes[0]); - await awaitTranslation(); + domTranslationProcessor?.handleNode(div0); + await awaitTranslation(); - domTranslationProcessor?.updateNode(div0.childNodes[0]); - await awaitTranslation(); + // update element - expect(div0.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); + const newText = 'Goodbye world!'; + div0.innerHTML = newText; + domTranslationProcessor?.handleNode(div0.childNodes[0]); + await awaitTranslation(); - expect(updateNodesSpy).toBeCalledTimes(1); - expect(updateNodesSpy.mock.calls[0][0]).toMatchObject( - containsRegex(TRANSLATION_SYMBOL), - ); - }); + domTranslationProcessor?.updateNode(div0.childNodes[0]); + await awaitTranslation(); + + expect(div0.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); + + expect(updateNodesSpy).toBeCalledTimes(1); + expect(updateNodesSpy.mock.calls[0][0]).toMatchObject( + containsRegex(TRANSLATION_SYMBOL), + ); }); }); diff --git a/src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap b/src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap index b23f710..3a22169 100644 --- a/src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap +++ b/src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`base usage > translate whole document and disable translation 1`] = ` +exports[`base usage > transalate whole document 1`] = ` " ***TRANSLATED***Demo page with challenges for DOM translator @@ -74,82 +74,5 @@ exports[`base usage > translate whole document and disable translation 1`] = ` -" -`; - -exports[`base usage > translate whole document and disable translation with lazyTranslate 1`] = ` -" - - ***TRANSLATED***Demo page with challenges for DOM translator - - - - - - -***TRANSLATED*** - Text with no container -
***TRANSLATED*** - Text in div
-

***TRANSLATED***Text inside container ***TRANSLATED***link text

- - image alt text -
***TRANSLATED***Text with preserved formatting
***TRANSLATED*** - - Some code: -
			***TRANSLATED***
-				const name = "Jeff";
-				console.log("Your name is " + name);
-			
-		
- -
-
-

***TRANSLATED***Words can be like X-rays, if you use them properly—they’ll go through anything. You read and you’re pierced.

-
-
***TRANSLATED***—Aldous Huxley, ***TRANSLATED***Brave New World
-
- -
- - - - - - - - - - - -
- -
-
***TRANSLATED***This text must not be translated ***TRANSLATED***this and ***TRANSLATED***this***TRANSLATED*** text too***TRANSLATED***.
-
***TRANSLATED***This text must not be translated, since it is editable
- -
***TRANSLATED***Text with preserved formatting
- -
-
***TRANSLATED***Items must not be translated:
-
***TRANSLATED***Foo
-
***TRANSLATED***Bar ***TRANSLATED***baz
-
- - - -
-
- - - - " `; From 79aeaca3965ad9e0a33708fd414e6f1ac1ecc823 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 19 Mar 2025 01:23:37 +0100 Subject: [PATCH 017/313] refactor: remove class by merge its into another --- src/DomTranslationProcessor.ts | 77 +++++++++++-- src/NodesTranslator.ts | 3 +- src/Translator.ts | 70 ------------ src/__tests__/DomTranslationProcessor.test.ts | 3 +- src/__tests__/Translator.test.ts | 103 ------------------ 5 files changed, 70 insertions(+), 186 deletions(-) delete mode 100644 src/Translator.ts delete mode 100644 src/__tests__/Translator.test.ts diff --git a/src/DomTranslationProcessor.ts b/src/DomTranslationProcessor.ts index 440b86a..f6ee374 100644 --- a/src/DomTranslationProcessor.ts +++ b/src/DomTranslationProcessor.ts @@ -1,24 +1,23 @@ import { NodeStorage } from './NodeStorage'; -import { Translator } from './Translator'; +import { isInViewport } from './utils/isInViewport'; import { nodeExplore } from './utils/nodeExplore'; +import { TranslatorInterface } from '.'; type IsTranslatableNode = (node: Node) => boolean; export class DomTranslationProcessor { private isTranslatableNode: IsTranslatableNode; - private nodeStorage: NodeStorage; - - private translator: Translator; + private readonly translateCallback: TranslatorInterface; constructor( isTranslatableNode: IsTranslatableNode, nodeStorage: NodeStorage, - translator: Translator, + translateCallback: TranslatorInterface, ) { this.isTranslatableNode = isTranslatableNode; this.nodeStorage = nodeStorage; - this.translator = translator; + this.translateCallback = translateCallback; } public isNodeStorageHas(node: Node) { @@ -42,7 +41,7 @@ export class DomTranslationProcessor { // Skip not translatable nodes if (!this.isTranslatableNode(node)) return; - const priority = this.translator.getNodePriority(node); + const priority = this.getNodePriority(node); this.nodeStorage.add(node, priority); @@ -50,7 +49,7 @@ export class DomTranslationProcessor { if (nodeData === undefined) { throw new Error('Node is not register'); } - this.translator.translateNode(node, nodeData); + this.translateNode(node); }; public processNodesInElement(element: Element, callback: (node: Node) => void) { @@ -83,7 +82,7 @@ export class DomTranslationProcessor { if (nodeData !== undefined) { this.nodeStorage.update(node); - this.translator.translateNode(node, nodeData); + this.translateNode(node); } } @@ -110,4 +109,64 @@ export class DomTranslationProcessor { } }); } + + /** + * Calculate node priority for translate, the bigger number the importance text + */ + private getNodePriority = (node: Node) => { + let score = 0; + + if (node instanceof Attr) { + score += 1; + const parent = node.ownerElement; + if (parent && isInViewport(parent)) { + // Attribute of visible element is important than text of non-visible element + score += 2; + } + } else if (node instanceof Text) { + score += 2; + const parent = node.parentElement; + if (parent && isInViewport(parent)) { + // Text of visible element is most important node for translation + score += 2; + } + } + + return score; + }; + + /** + * Call only for new and updated nodes + */ + private translateNode(node: Node) { + const nodeData = this.nodeStorage.get(node); + if (nodeData === undefined) { + throw new Error('Node is not register'); + } + + if (node.nodeValue === null) return; + + // Recursion prevention + if (nodeData.updateId <= nodeData.translateContext) { + return; + } + + const nodeId = nodeData.id; + const nodeContext = nodeData.updateId; + return this.translateCallback(node.nodeValue, nodeData.priority).then((text) => { + const actualNodeData = this.nodeStorage.get(node); + if (actualNodeData === undefined || nodeId !== actualNodeData.id) { + return; + } + if (nodeContext !== actualNodeData.updateId) { + return; + } + + // actualNodeData.translateData = text; + actualNodeData.originalText = node.nodeValue !== null ? node.nodeValue : ''; + actualNodeData.translateContext = actualNodeData.updateId + 1; + node.nodeValue = text; + return node; + }); + } } diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 2687d0c..09fbdcb 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -2,7 +2,6 @@ import { DomTranslationProcessor } from './DomTranslationProcessor'; import { LazyTranslator } from './LazyTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; import { NodeStorage } from './NodeStorage'; -import { Translator } from './Translator'; import { configureTranslatableNodePredicate } from './utils/nodes'; export interface InnerConfig { @@ -41,7 +40,7 @@ export class NodesTranslator { this.domTranslationProcessor = new DomTranslationProcessor( this.config.isTranslatableNode, new NodeStorage(), - new Translator(translateCallback), + translateCallback, ); this.lazyTranslator = new LazyTranslator( diff --git a/src/Translator.ts b/src/Translator.ts deleted file mode 100644 index 0777d07..0000000 --- a/src/Translator.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { NodeData } from './NodeStorage'; -import { TranslatorInterface } from './NodesTranslator'; -import { isInViewport } from './utils/isInViewport'; - -/** - * The Translator class defines the translation logic - */ -export class Translator { - private readonly translateCallback: TranslatorInterface; - - constructor(translateCallback: TranslatorInterface) { - this.translateCallback = translateCallback; - } - - /** - * Calculate node priority for translate, the bigger number the importance text - */ - public getNodePriority = (node: Node) => { - let score = 0; - - if (node instanceof Attr) { - score += 1; - const parent = node.ownerElement; - if (parent && isInViewport(parent)) { - // Attribute of visible element is important than text of non-visible element - score += 2; - } - } else if (node instanceof Text) { - score += 2; - const parent = node.parentElement; - if (parent && isInViewport(parent)) { - // Text of visible element is most important node for translation - score += 2; - } - } - - return score; - }; - - /** - * Call only for new and updated nodes - */ - public translateNode(node: Node, nodeData: NodeData) { - if (node.nodeValue === null) return; - - // Recursion prevention - if (nodeData.updateId <= nodeData.translateContext) { - return; - } - - const nodeId = nodeData.id; - const nodeContext = nodeData.updateId; - return this.translateCallback(node.nodeValue, nodeData.priority).then((text) => { - // const actualNodeData = getNodeData(node); - // const nodeData = nodeData; - if (nodeData === undefined || nodeId !== nodeData.id) { - return; - } - if (nodeContext !== nodeData.updateId) { - return; - } - - // actualNodeData.translateData = text; - nodeData.originalText = node.nodeValue !== null ? node.nodeValue : ''; - nodeData.translateContext = nodeData.updateId + 1; - node.nodeValue = text; - return node; - }); - } -} diff --git a/src/__tests__/DomTranslationProcessor.test.ts b/src/__tests__/DomTranslationProcessor.test.ts index 6cf20f8..e33dcd8 100644 --- a/src/__tests__/DomTranslationProcessor.test.ts +++ b/src/__tests__/DomTranslationProcessor.test.ts @@ -2,7 +2,6 @@ import { readFileSync } from 'fs'; import { DomTranslationProcessor } from '../DomTranslationProcessor'; import { NodeStorage } from '../NodeStorage'; -import { Translator } from '../Translator'; import { awaitTranslation, containsRegex, @@ -22,7 +21,7 @@ describe('base usage', () => { domTranslationProcessor = new DomTranslationProcessor( isTranslatableNode, new NodeStorage(), - new Translator(translator), + translator, ); }); diff --git a/src/__tests__/Translator.test.ts b/src/__tests__/Translator.test.ts deleted file mode 100644 index 15274e2..0000000 --- a/src/__tests__/Translator.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Translator } from '../Translator'; -import { containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; - -describe('Translator', () => { - let translate: Translator; - let div: HTMLElement; - let attr: Attr; - - beforeEach(() => { - translate = new Translator(translator); - - div = document.createElement('div'); - attr = document.createAttribute('title'); - attr.value = 'Hello attribute!'; - div.setAttributeNode(attr); - }); - - describe('translate method', () => { - test('successful translate text and attr node', async () => { - div.textContent = 'Hello world!'; - - const textNode = div.firstChild; - const attrNode = div.lastChild; - if (!(textNode instanceof Text) || !(attrNode instanceof Attr)) { - return; - } - - // trasnale text node - await expect( - translate.translateNode(textNode, { - id: 0, - originalText: null, - priority: 1, - translateContext: 0, - updateId: 1, - }), - ).resolves.toMatchObject(containsRegex(TRANSLATION_SYMBOL)); - - // translate attr node - await expect( - translate.translateNode(attr, { - id: 0, - originalText: null, - priority: 1, - translateContext: 0, - updateId: 1, - }), - ).resolves.toMatchObject(containsRegex(TRANSLATION_SYMBOL)); - }); - - test('not translate', () => { - // not translate if translateContext == updateId - // Recursion prevention - - const textNode = div.firstChild; - if (!(textNode instanceof Text)) { - return; - } - - expect( - translate.translateNode(textNode, { - id: 0, - originalText: null, - priority: 1, - translateContext: 1, - updateId: 1, - }), - ).toBe(undefined); - }); - - test('not translate if nodeValue null', async () => { - div.textContent = null; - - // if nodeValue is null not translate - expect( - translate.translateNode(div, { - id: 0, - originalText: null, - priority: 1, - translateContext: 0, - updateId: 1, - }), - ).toBe(undefined); - }); - }); - - describe('getNodePriority method', () => { - test('get currect priority for text node and atribute', () => { - const textNode = div.firstChild; - if (!(textNode instanceof Text)) { - return; - } - - expect(translate.getNodePriority(textNode)).toBe(2); - - expect(translate.getNodePriority(attr)).toBe(2); - }); - - test('priority for not attr and node is 0', () => { - expect(translate.getNodePriority(div)).toBe(0); - }); - }); -}); From 7a0cbc8700c7f25bf0aa155f00b673c4a5c5a61b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 19 Mar 2025 01:24:25 +0100 Subject: [PATCH 018/313] chore: add comment to class --- src/NodeStorage.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/NodeStorage.ts b/src/NodeStorage.ts index a4b5c1a..1af15fb 100644 --- a/src/NodeStorage.ts +++ b/src/NodeStorage.ts @@ -26,6 +26,10 @@ export interface NodeData { priority: number; } +/** + * The NodeStorage class encapsulates node storage, manages node metadata + */ + export class NodeStorage { private idCounter = 0; private nodeStorage = new WeakMap(); From 8c197411c1968db556e3cfdf6f051c1496589722 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 21 Mar 2025 21:15:20 +0100 Subject: [PATCH 019/313] chore: rename methods --- src/LazyTranslator.ts | 8 ++------ src/NodesTranslator.ts | 4 ++-- src/__tests__/LazyTranslator.test.ts | 8 ++++---- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/LazyTranslator.ts b/src/LazyTranslator.ts index 23b1e21..9d262bd 100644 --- a/src/LazyTranslator.ts +++ b/src/LazyTranslator.ts @@ -72,11 +72,7 @@ export class LazyTranslator { this.itersectObserver.observe(node); } - /** - * The process method determines whether the node can be processed later; otherwise, processes it immediately - */ - - public handleNode(node: Node) { + public isLazilyTranslatable(node: Node) { // Lazy translate when own element intersect viewport // But translate at once if node have not parent (virtual node) or parent node is outside of body (utility tags like meta or title) @@ -97,7 +93,7 @@ export class LazyTranslator { return false; } - public stopHandling(node: Element) { + public disableLazyTranslation(node: Element) { this.itersectStorage.delete(node); this.itersectObserver.unobserve(node); diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 09fbdcb..590e700 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -61,7 +61,7 @@ export class NodesTranslator { // if an element can't be translated later, translate it immediately - if (this.config.lazyTranslate && this.lazyTranslator.handleNode(node)) { + if (this.config.lazyTranslate && this.lazyTranslator.isLazilyTranslatable(node)) { return; } @@ -72,7 +72,7 @@ export class NodesTranslator { this.domTranslationProcessor.deleteNode(node); if (node instanceof Element) { - this.lazyTranslator.stopHandling(node); + this.lazyTranslator.disableLazyTranslation(node); } } diff --git a/src/__tests__/LazyTranslator.test.ts b/src/__tests__/LazyTranslator.test.ts index d4077f9..eb55ec2 100644 --- a/src/__tests__/LazyTranslator.test.ts +++ b/src/__tests__/LazyTranslator.test.ts @@ -25,7 +25,7 @@ describe('base usage', () => { test('translate element at intersection', async () => { const lazyTraslator = new LazyTranslator(isTranslatableNode, translator); - const isLazyTranslate = lazyTraslator.handleNode(textNode); + const isLazyTranslate = lazyTraslator.isLazilyTranslatable(textNode); await awaitTranslation(); @@ -43,7 +43,7 @@ describe('base usage', () => { root: divElement, }); - const isLazyTranslate = lazyTraslator.handleNode(textNode); + const isLazyTranslate = lazyTraslator.isLazilyTranslatable(textNode); await awaitTranslation(); @@ -61,7 +61,7 @@ describe('base usage', () => { const lazyTraslator = new LazyTranslator(isTranslatableNode, translator); - const isLazyTranslate = lazyTraslator.handleNode(textNode); + const isLazyTranslate = lazyTraslator.isLazilyTranslatable(textNode); await awaitTranslation(); @@ -78,7 +78,7 @@ describe('base usage', () => { root: divElement, }); - const isLazyTranslate = lazyTraslator.handleNode(textNode); + const isLazyTranslate = lazyTraslator.isLazilyTranslatable(textNode); await awaitTranslation(); From 1d74d12a35b377ec045ed25f5789ef3469dda95b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 21 Mar 2025 21:26:41 +0100 Subject: [PATCH 020/313] refactor: explicitly return null --- src/NodeStorage.ts | 2 +- src/__tests__/NodeStorage.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/NodeStorage.ts b/src/NodeStorage.ts index 1af15fb..1df1787 100644 --- a/src/NodeStorage.ts +++ b/src/NodeStorage.ts @@ -39,7 +39,7 @@ export class NodeStorage { } public get(node: Node) { - return this.nodeStorage.get(node); + return this.nodeStorage.get(node) ?? null; } public add(node: Node, priority: number) { diff --git a/src/__tests__/NodeStorage.test.ts b/src/__tests__/NodeStorage.test.ts index 53b7651..8c26f22 100644 --- a/src/__tests__/NodeStorage.test.ts +++ b/src/__tests__/NodeStorage.test.ts @@ -13,9 +13,9 @@ describe('NodeStorage', () => { div1 = document.createElement('div'); }); - test('return false and undefined for a node that is not added', () => { + test('return correct value for a node that is not added', () => { expect(nodeStorage.has(div)).toBe(false); - expect(nodeStorage.get(div)).toBeUndefined(); + expect(nodeStorage.get(div)).toBeNull(); }); test('add a node to storage', () => { @@ -70,7 +70,7 @@ describe('NodeStorage', () => { nodeStorage.add(div, 1); nodeStorage.delete(div); - expect(nodeStorage.get(div)).toBeUndefined(); + expect(nodeStorage.get(div)).toBeNull(); }); test('not throw if deleting a non-existent node', () => { From a45d136ead23da041100abff727bafaa62805bb8 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 21 Mar 2025 22:06:41 +0100 Subject: [PATCH 021/313] refactor: rename, remove unnecessary code --- src/DomTranslationProcessor.ts | 31 ++++++------ src/NodesTranslator.ts | 4 +- src/__tests__/DomTranslationProcessor.test.ts | 47 ++++--------------- 3 files changed, 25 insertions(+), 57 deletions(-) diff --git a/src/DomTranslationProcessor.ts b/src/DomTranslationProcessor.ts index f6ee374..100d295 100644 --- a/src/DomTranslationProcessor.ts +++ b/src/DomTranslationProcessor.ts @@ -26,13 +26,16 @@ export class DomTranslationProcessor { public getOriginalNodeText(node: Node) { const nodeData = this.nodeStorage.get(node); - if (nodeData === undefined) return null; - const { originalText } = nodeData; - return { originalText }; + return nodeData ? { originalText: nodeData.originalText } : null; } - public handleNode = (node: Node) => { + public addNode = (node: Node) => { + if (node instanceof Element) { + this.processNodesInElement(node, this.addNode); + return; + } + if (this.isNodeStorageHas(node)) return; // Skip empthy text @@ -45,10 +48,6 @@ export class DomTranslationProcessor { this.nodeStorage.add(node, priority); - const nodeData = this.nodeStorage.get(node); - if (nodeData === undefined) { - throw new Error('Node is not register'); - } this.translateNode(node); }; @@ -77,13 +76,13 @@ export class DomTranslationProcessor { // Updates never be lazy public updateNode(node: Node) { - const nodeData = this.nodeStorage.get(node); - - if (nodeData !== undefined) { - this.nodeStorage.update(node); - - this.translateNode(node); + // update only if the node is in storage + if (!this.nodeStorage.get(node)) { + return; } + + this.nodeStorage.update(node); + this.translateNode(node); } /** @@ -140,7 +139,7 @@ export class DomTranslationProcessor { */ private translateNode(node: Node) { const nodeData = this.nodeStorage.get(node); - if (nodeData === undefined) { + if (!nodeData) { throw new Error('Node is not register'); } @@ -155,7 +154,7 @@ export class DomTranslationProcessor { const nodeContext = nodeData.updateId; return this.translateCallback(node.nodeValue, nodeData.priority).then((text) => { const actualNodeData = this.nodeStorage.get(node); - if (actualNodeData === undefined || nodeId !== actualNodeData.id) { + if (!actualNodeData || nodeId !== actualNodeData.id) { return; } if (nodeContext !== actualNodeData.updateId) { diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 590e700..65c8963 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -45,7 +45,7 @@ export class NodesTranslator { this.lazyTranslator = new LazyTranslator( this.config.isTranslatableNode, - this.domTranslationProcessor.handleNode, + this.domTranslationProcessor.addNode, ); } @@ -65,7 +65,7 @@ export class NodesTranslator { return; } - this.domTranslationProcessor.handleNode(node); + this.domTranslationProcessor.addNode(node); } private deleteNode(node: Node) { diff --git a/src/__tests__/DomTranslationProcessor.test.ts b/src/__tests__/DomTranslationProcessor.test.ts index e33dcd8..a3492f6 100644 --- a/src/__tests__/DomTranslationProcessor.test.ts +++ b/src/__tests__/DomTranslationProcessor.test.ts @@ -13,7 +13,7 @@ import { const sample = readFileSync(__dirname + '/sample.html', 'utf8'); describe('base usage', () => { - let domTranslationProcessor: DomTranslationProcessor | null; + let domTranslationProcessor: DomTranslationProcessor; beforeEach(() => { const isTranslatableNode = (node: Node) => node instanceof Text; @@ -25,35 +25,20 @@ describe('base usage', () => { ); }); - afterEach(() => { - domTranslationProcessor = null; - }); - test('transalate whole document', async () => { fillDocument(sample); const parsedHTML = document.documentElement.outerHTML; - // handle all translatable nodes from element - - if (document.documentElement instanceof Element) { - domTranslationProcessor?.processNodesInElement( - document.documentElement, - (node) => { - domTranslationProcessor?.handleNode(node); - }, - ); - } - // translate document - domTranslationProcessor?.handleNode(document.documentElement); + domTranslationProcessor.addNode(document.documentElement); await awaitTranslation(); expect(document.documentElement.outerHTML).toMatchSnapshot(); // disable translation - domTranslationProcessor?.deleteNode(document.documentElement); + domTranslationProcessor.deleteNode(document.documentElement); expect(document.documentElement.outerHTML).toBe(parsedHTML); }); @@ -63,20 +48,12 @@ describe('base usage', () => { const div0 = document.createElement('div'); div0.innerHTML = originalText; - // handle all translatable nodes from element - - if (div0 instanceof Element) { - domTranslationProcessor?.processNodesInElement(div0, (node) => { - domTranslationProcessor?.handleNode(node); - }); - } - // translate document + domTranslationProcessor.addNode(div0); - domTranslationProcessor?.handleNode(div0); await awaitTranslation(); - expect(domTranslationProcessor?.getOriginalNodeText(div0.childNodes[0])).toEqual( + expect(domTranslationProcessor.getOriginalNodeText(div0.childNodes[0])).toEqual( expect.objectContaining({ originalText: originalText, }), @@ -94,25 +71,17 @@ describe('base usage', () => { 'updateNode', ); - // handle all translatable nodes from element - - if (div0 instanceof Element) { - domTranslationProcessor?.processNodesInElement(div0, (node) => { - domTranslationProcessor?.handleNode(node); - }); - } - - domTranslationProcessor?.handleNode(div0); + domTranslationProcessor.addNode(div0); await awaitTranslation(); // update element const newText = 'Goodbye world!'; div0.innerHTML = newText; - domTranslationProcessor?.handleNode(div0.childNodes[0]); + domTranslationProcessor.addNode(div0.childNodes[0]); await awaitTranslation(); - domTranslationProcessor?.updateNode(div0.childNodes[0]); + domTranslationProcessor.updateNode(div0.childNodes[0]); await awaitTranslation(); expect(div0.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); From 4700988d162f83e4eb4a0e60512eaee6c212654b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 21 Mar 2025 22:17:23 +0100 Subject: [PATCH 022/313] fix: remove duplicate type definition --- src/DomTranslationProcessor.ts | 3 +-- src/LazyTranslator.ts | 6 ++++-- src/types.ts | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 src/types.ts diff --git a/src/DomTranslationProcessor.ts b/src/DomTranslationProcessor.ts index 100d295..43dc68e 100644 --- a/src/DomTranslationProcessor.ts +++ b/src/DomTranslationProcessor.ts @@ -1,10 +1,9 @@ import { NodeStorage } from './NodeStorage'; +import { IsTranslatableNode } from './types'; import { isInViewport } from './utils/isInViewport'; import { nodeExplore } from './utils/nodeExplore'; import { TranslatorInterface } from '.'; -type IsTranslatableNode = (node: Node) => boolean; - export class DomTranslationProcessor { private isTranslatableNode: IsTranslatableNode; private nodeStorage: NodeStorage; diff --git a/src/LazyTranslator.ts b/src/LazyTranslator.ts index 9d262bd..473621a 100644 --- a/src/LazyTranslator.ts +++ b/src/LazyTranslator.ts @@ -1,3 +1,5 @@ +import { IsTranslatableNode } from './types'; + type Translator = (node: Node) => void; type IntersectionConfig = { @@ -19,14 +21,14 @@ function isIntersectableNode(node: Element) { */ export class LazyTranslator { private translator?: Translator; - private readonly isTranslatableNode: (node: Node) => boolean; + private readonly isTranslatableNode: IsTranslatableNode; private readonly itersectStorage = new WeakSet(); private itersectObserver: IntersectionObserver; constructor( - isTranslatableNode: (node: Node) => boolean, + isTranslatableNode: IsTranslatableNode, transaltor: Translator, intersectionConfig: IntersectionConfig = { root: null, diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e349749 --- /dev/null +++ b/src/types.ts @@ -0,0 +1 @@ +export type IsTranslatableNode = (node: Node) => boolean; From 1be7b76d1e82779618a06cc32b148e22ae337f8c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 21 Mar 2025 23:26:13 +0100 Subject: [PATCH 023/313] test: add test case --- src/__tests__/DomTranslationProcessor.test.ts | 120 +++++++++++++++--- 1 file changed, 105 insertions(+), 15 deletions(-) diff --git a/src/__tests__/DomTranslationProcessor.test.ts b/src/__tests__/DomTranslationProcessor.test.ts index a3492f6..f4aed7d 100644 --- a/src/__tests__/DomTranslationProcessor.test.ts +++ b/src/__tests__/DomTranslationProcessor.test.ts @@ -14,8 +14,12 @@ const sample = readFileSync(__dirname + '/sample.html', 'utf8'); describe('base usage', () => { let domTranslationProcessor: DomTranslationProcessor; + let div: Element; beforeEach(() => { + div = document.createElement('div'); + div.innerHTML = 'Hello world!'; + const isTranslatableNode = (node: Node) => node instanceof Text; domTranslationProcessor = new DomTranslationProcessor( @@ -27,7 +31,6 @@ describe('base usage', () => { test('transalate whole document', async () => { fillDocument(sample); - const parsedHTML = document.documentElement.outerHTML; // translate document @@ -45,46 +48,133 @@ describe('base usage', () => { test('getNodeData returns the original text', async () => { const originalText = 'Hello world!'; - const div0 = document.createElement('div'); - div0.innerHTML = originalText; - // translate document - domTranslationProcessor.addNode(div0); + domTranslationProcessor.addNode(div); await awaitTranslation(); - expect(domTranslationProcessor.getOriginalNodeText(div0.childNodes[0])).toEqual( + expect(domTranslationProcessor.getOriginalNodeText(div.childNodes[0])).toEqual( expect.objectContaining({ originalText: originalText, }), ); }); + test('not translate empy element', async () => { + div.innerHTML = ''; + // translate document + domTranslationProcessor.addNode(div); + + await awaitTranslation(); + expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + }); + + test('process the element tree', () => { + const div1 = document.createElement('div'); + div1.innerHTML = 'Hello world too!'; + div.append(div1); + + const spy = vi.fn((node: Node) => { + if (node.textContent) { + translator(node.textContent); + } + }); + + domTranslationProcessor.processNodesInElement(div, spy); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith(div1.childNodes[0]); + expect(spy).not.toHaveBeenCalledWith(div1); + }); + + test('process the element tree with shadow dom', async () => { + const container = document.createElement('div'); + const shadowRoot = container.attachShadow({ mode: 'open' }); + const shadowElement = document.createElement('p'); + shadowElement.textContent = 'Shadow text'; + shadowElement.setAttribute('data-test', 'value'); + shadowRoot.appendChild(shadowElement); + + const spy = vi.fn(); + + domTranslationProcessor.processNodesInElement(container, spy); + await awaitTranslation(); + + expect(spy).toHaveBeenCalledWith(shadowElement.firstChild); + expect(spy).toBeCalledTimes(1); + }); + + test('disable translation only for the target node', async () => { + const div1 = document.createElement('div'); + div1.innerHTML = 'Hello world too!'; + div.append(div1); + + domTranslationProcessor.addNode(div); + await awaitTranslation(); + + domTranslationProcessor.deleteNode(div, true); + + // child node has translated text + expect(div1.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); + }); + + test('isNodeStorageHas returns true if element is stored', async () => { + domTranslationProcessor.addNode(div); + await awaitTranslation(); + + expect(domTranslationProcessor.isNodeStorageHas(div.childNodes[0])).toBe(true); + }); + + test('translates element and node correctly', async () => { + // translate text node + + const textNode = div.childNodes[0]; + + domTranslationProcessor.addNode(textNode); + await awaitTranslation(); + expect(textNode.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // reset translation for text node + domTranslationProcessor.deleteNode(textNode); + expect(textNode.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // translate element + + const div1 = document.createElement('div'); + div1.innerHTML = 'Hello world 2!'; + + domTranslationProcessor.addNode(div1); + await awaitTranslation(); + expect(div1.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // reset translation for element + domTranslationProcessor.deleteNode(div1); + expect(div1.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + }); + test('updateNode should be call ones', async () => { - const div0 = document.createElement('div'); - div0.innerHTML = 'Hello world!'; - document.body.appendChild(div0); + document.body.appendChild(div); - // Spy on the updateNode method + // spy on the updateNode method const updateNodesSpy = vi.spyOn( domTranslationProcessor as DomTranslationProcessor, 'updateNode', ); - domTranslationProcessor.addNode(div0); + domTranslationProcessor.addNode(div); await awaitTranslation(); // update element const newText = 'Goodbye world!'; - div0.innerHTML = newText; - domTranslationProcessor.addNode(div0.childNodes[0]); + div.innerHTML = newText; + domTranslationProcessor.addNode(div.childNodes[0]); await awaitTranslation(); - domTranslationProcessor.updateNode(div0.childNodes[0]); + domTranslationProcessor.updateNode(div.childNodes[0]); await awaitTranslation(); - expect(div0.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(updateNodesSpy).toBeCalledTimes(1); expect(updateNodesSpy.mock.calls[0][0]).toMatchObject( From 7069c8ec8738ca0468bed1b341375c1494d49c82 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 22 Mar 2025 00:57:27 +0100 Subject: [PATCH 024/313] chore: rename --- src/DomTranslationProcessor.ts | 6 +++--- src/LazyTranslator.ts | 6 +++--- src/types.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/DomTranslationProcessor.ts b/src/DomTranslationProcessor.ts index 43dc68e..10db684 100644 --- a/src/DomTranslationProcessor.ts +++ b/src/DomTranslationProcessor.ts @@ -1,16 +1,16 @@ import { NodeStorage } from './NodeStorage'; -import { IsTranslatableNode } from './types'; +import { TranslatableNodePredicate } from './types'; import { isInViewport } from './utils/isInViewport'; import { nodeExplore } from './utils/nodeExplore'; import { TranslatorInterface } from '.'; export class DomTranslationProcessor { - private isTranslatableNode: IsTranslatableNode; + private isTranslatableNode: TranslatableNodePredicate; private nodeStorage: NodeStorage; private readonly translateCallback: TranslatorInterface; constructor( - isTranslatableNode: IsTranslatableNode, + isTranslatableNode: TranslatableNodePredicate, nodeStorage: NodeStorage, translateCallback: TranslatorInterface, ) { diff --git a/src/LazyTranslator.ts b/src/LazyTranslator.ts index 473621a..0e33890 100644 --- a/src/LazyTranslator.ts +++ b/src/LazyTranslator.ts @@ -1,4 +1,4 @@ -import { IsTranslatableNode } from './types'; +import { TranslatableNodePredicate } from './types'; type Translator = (node: Node) => void; @@ -21,14 +21,14 @@ function isIntersectableNode(node: Element) { */ export class LazyTranslator { private translator?: Translator; - private readonly isTranslatableNode: IsTranslatableNode; + private readonly isTranslatableNode: TranslatableNodePredicate; private readonly itersectStorage = new WeakSet(); private itersectObserver: IntersectionObserver; constructor( - isTranslatableNode: IsTranslatableNode, + isTranslatableNode: TranslatableNodePredicate, transaltor: Translator, intersectionConfig: IntersectionConfig = { root: null, diff --git a/src/types.ts b/src/types.ts index e349749..44a84a0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1 +1 @@ -export type IsTranslatableNode = (node: Node) => boolean; +export type TranslatableNodePredicate = (node: Node) => boolean; From c7539ac68a21fe1ae97d84063df0daa7c73569fb Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 22 Mar 2025 19:01:05 +0100 Subject: [PATCH 025/313] test: add expects in tests --- src/__tests__/DomTranslationProcessor.test.ts | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/__tests__/DomTranslationProcessor.test.ts b/src/__tests__/DomTranslationProcessor.test.ts index f4aed7d..e235742 100644 --- a/src/__tests__/DomTranslationProcessor.test.ts +++ b/src/__tests__/DomTranslationProcessor.test.ts @@ -16,6 +16,16 @@ describe('base usage', () => { let domTranslationProcessor: DomTranslationProcessor; let div: Element; + const spy = vi.fn(async (node: Node) => { + if (node.textContent) { + node.textContent = await translator(node.textContent); + } + }); + + afterEach(() => { + spy.mockClear(); + }); + beforeEach(() => { div = document.createElement('div'); div.innerHTML = 'Hello world!'; @@ -69,22 +79,18 @@ describe('base usage', () => { expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); - test('process the element tree', () => { + test('process the element tree', async () => { const div1 = document.createElement('div'); div1.innerHTML = 'Hello world too!'; div.append(div1); - const spy = vi.fn((node: Node) => { - if (node.textContent) { - translator(node.textContent); - } - }); - domTranslationProcessor.processNodesInElement(div, spy); + await awaitTranslation(); expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledWith(div1.childNodes[0]); expect(spy).not.toHaveBeenCalledWith(div1); + expect(div1.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); test('process the element tree with shadow dom', async () => { @@ -95,13 +101,12 @@ describe('base usage', () => { shadowElement.setAttribute('data-test', 'value'); shadowRoot.appendChild(shadowElement); - const spy = vi.fn(); - domTranslationProcessor.processNodesInElement(container, spy); await awaitTranslation(); expect(spy).toHaveBeenCalledWith(shadowElement.firstChild); expect(spy).toBeCalledTimes(1); + expect(shadowElement.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); test('disable translation only for the target node', async () => { @@ -109,12 +114,26 @@ describe('base usage', () => { div1.innerHTML = 'Hello world too!'; div.append(div1); + // delete the target element and its nested items domTranslationProcessor.addNode(div); await awaitTranslation(); - domTranslationProcessor.deleteNode(div, true); + domTranslationProcessor.deleteNode(div); - // child node has translated text + // child node and target has not translated text + expect(div1.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // delete translation only for the target element + domTranslationProcessor.addNode(div); + await awaitTranslation(); + + domTranslationProcessor.deleteNode(div.childNodes[0], true); + + expect(div.childNodes[0].textContent).not.toMatch( + containsRegex(TRANSLATION_SYMBOL), + ); + // child element still has translation expect(div1.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); From 9526a9de03043d22cd2b3b05984bcec17b6ddb24 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 22 Mar 2025 19:21:00 +0100 Subject: [PATCH 026/313] refactor: remove optionality for property --- src/LazyTranslator.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/LazyTranslator.ts b/src/LazyTranslator.ts index 0e33890..ebb2cc7 100644 --- a/src/LazyTranslator.ts +++ b/src/LazyTranslator.ts @@ -20,7 +20,7 @@ function isIntersectableNode(node: Element) { * by default, the top-level document's viewport. */ export class LazyTranslator { - private translator?: Translator; + private translator: Translator; private readonly isTranslatableNode: TranslatableNodePredicate; private readonly itersectStorage = new WeakSet(); @@ -61,8 +61,6 @@ export class LazyTranslator { if (node instanceof Element || !this.isTranslatableNode(node)) { return; } - - if (!this.translator) throw new Error('expect node handler'); this.translator(node); }); } @@ -70,7 +68,6 @@ export class LazyTranslator { private handleElementByIntersectViewport(node: Element) { if (this.itersectStorage.has(node)) return; this.itersectStorage.add(node); - this.itersectObserver.observe(node); } From 9026430712e05a84a3b14300f5f783460a590451 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 11 Apr 2025 22:27:44 +0200 Subject: [PATCH 027/313] refactor: improve code --- src/DomTranslationProcessor.ts | 69 ++++++--------- src/__tests__/DomTranslationProcessor.test.ts | 85 +++++++++---------- .../DomTranslationProcessor.test.ts.snap | 2 +- 3 files changed, 71 insertions(+), 85 deletions(-) diff --git a/src/DomTranslationProcessor.ts b/src/DomTranslationProcessor.ts index 10db684..944c106 100644 --- a/src/DomTranslationProcessor.ts +++ b/src/DomTranslationProcessor.ts @@ -4,20 +4,15 @@ import { isInViewport } from './utils/isInViewport'; import { nodeExplore } from './utils/nodeExplore'; import { TranslatorInterface } from '.'; -export class DomTranslationProcessor { - private isTranslatableNode: TranslatableNodePredicate; - private nodeStorage: NodeStorage; - private readonly translateCallback: TranslatorInterface; - +/** + * Class DomTranslationProcessor responsible for translating DOM nodes + */ +export class DomNodesTranslator { constructor( - isTranslatableNode: TranslatableNodePredicate, - nodeStorage: NodeStorage, - translateCallback: TranslatorInterface, - ) { - this.isTranslatableNode = isTranslatableNode; - this.nodeStorage = nodeStorage; - this.translateCallback = translateCallback; - } + private isTranslatableNode: TranslatableNodePredicate, + private nodeStorage: NodeStorage, + private readonly translateCallback: TranslatorInterface, + ) {} public isNodeStorageHas(node: Node) { return this.nodeStorage.has(node); @@ -30,44 +25,25 @@ export class DomTranslationProcessor { } public addNode = (node: Node) => { - if (node instanceof Element) { - this.processNodesInElement(node, this.addNode); - return; - } - if (this.isNodeStorageHas(node)) return; - // Skip empthy text + // Skip empty text if (node.nodeValue === null || node.nodeValue.trim().length == 0) return; // Skip not translatable nodes if (!this.isTranslatableNode(node)) return; - const priority = this.getNodePriority(node); - - this.nodeStorage.add(node, priority); + this.nodeStorage.add(node, this.getNodePriority(node)); this.translateNode(node); }; - public processNodesInElement(element: Element, callback: (node: Node) => void) { - this.handleTree(element, (node) => { - if (node instanceof Element) return; - - if (this.isTranslatableNode(node)) { - callback(node); - } - }); - } - public deleteNode(node: Node, onlyTarget = false) { - if (node instanceof Element) { + if (node instanceof Element && !onlyTarget) { // Delete all attributes and inner nodes - if (!onlyTarget) { - this.handleTree(node, (node) => { - this.deleteNode(node, true); - }); - } + this.handleTree(node, (node) => { + this.deleteNode(node, true); + }); } this.nodeStorage.delete(node); @@ -76,14 +52,25 @@ export class DomTranslationProcessor { // Updates never be lazy public updateNode(node: Node) { // update only if the node is in storage - if (!this.nodeStorage.get(node)) { - return; - } + if (!this.nodeStorage.get(node)) return; this.nodeStorage.update(node); this.translateNode(node); } + /** + * processNodesInElement execute callback only for translatable nodes, recursively traversing the element + */ + public processNodesInElement(element: Element, callback: (node: Node) => void) { + this.handleTree(element, (node) => { + if (node instanceof Element) return; + + if (this.isTranslatableNode(node)) { + callback(node); + } + }); + } + /** * Handle all translatable nodes from element * Element, Attr, Text diff --git a/src/__tests__/DomTranslationProcessor.test.ts b/src/__tests__/DomTranslationProcessor.test.ts index e235742..170a75d 100644 --- a/src/__tests__/DomTranslationProcessor.test.ts +++ b/src/__tests__/DomTranslationProcessor.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'fs'; -import { DomTranslationProcessor } from '../DomTranslationProcessor'; +import { DomNodesTranslator } from '../DomTranslationProcessor'; import { NodeStorage } from '../NodeStorage'; import { awaitTranslation, @@ -13,7 +13,7 @@ import { const sample = readFileSync(__dirname + '/sample.html', 'utf8'); describe('base usage', () => { - let domTranslationProcessor: DomTranslationProcessor; + let domTranslationProcessor: DomNodesTranslator; let div: Element; const spy = vi.fn(async (node: Node) => { @@ -32,20 +32,27 @@ describe('base usage', () => { const isTranslatableNode = (node: Node) => node instanceof Text; - domTranslationProcessor = new DomTranslationProcessor( + domTranslationProcessor = new DomNodesTranslator( isTranslatableNode, new NodeStorage(), translator, ); }); - test('transalate whole document', async () => { + test('translate whole document', async () => { fillDocument(sample); const parsedHTML = document.documentElement.outerHTML; // translate document + if (document.documentElement instanceof Element) { + domTranslationProcessor.processNodesInElement( + document.documentElement, + (node) => { + domTranslationProcessor.addNode(node); + }, + ); + } - domTranslationProcessor.addNode(document.documentElement); await awaitTranslation(); expect(document.documentElement.outerHTML).toMatchSnapshot(); @@ -58,8 +65,8 @@ describe('base usage', () => { test('getNodeData returns the original text', async () => { const originalText = 'Hello world!'; - // translate document - domTranslationProcessor.addNode(div); + // translate + domTranslationProcessor.addNode(div.childNodes[0]); await awaitTranslation(); @@ -70,13 +77,16 @@ describe('base usage', () => { ); }); - test('not translate empy element', async () => { - div.innerHTML = ''; + test('not translate empty element', async () => { + div.innerHTML = ' '; // translate document - domTranslationProcessor.addNode(div); + domTranslationProcessor.addNode(div.childNodes[0]); await awaitTranslation(); - expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + + expect(div.childNodes[0].textContent).not.toMatch( + containsRegex(TRANSLATION_SYMBOL), + ); }); test('process the element tree', async () => { @@ -110,12 +120,19 @@ describe('base usage', () => { }); test('disable translation only for the target node', async () => { + const handelTree = (node: Node) => { + if (node instanceof Element) { + domTranslationProcessor.processNodesInElement(node, (node) => { + domTranslationProcessor.addNode(node); + }); + } + }; const div1 = document.createElement('div'); div1.innerHTML = 'Hello world too!'; div.append(div1); // delete the target element and its nested items - domTranslationProcessor.addNode(div); + handelTree(div1); await awaitTranslation(); domTranslationProcessor.deleteNode(div); @@ -125,7 +142,7 @@ describe('base usage', () => { expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); // delete translation only for the target element - domTranslationProcessor.addNode(div); + handelTree(div); await awaitTranslation(); domTranslationProcessor.deleteNode(div.childNodes[0], true); @@ -137,38 +154,15 @@ describe('base usage', () => { expect(div1.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); - test('isNodeStorageHas returns true if element is stored', async () => { - domTranslationProcessor.addNode(div); + test('isNodeStorageHas returns correct result', async () => { + domTranslationProcessor.addNode(div.childNodes[0]); await awaitTranslation(); expect(domTranslationProcessor.isNodeStorageHas(div.childNodes[0])).toBe(true); - }); - - test('translates element and node correctly', async () => { - // translate text node - - const textNode = div.childNodes[0]; - domTranslationProcessor.addNode(textNode); - await awaitTranslation(); - expect(textNode.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - - // reset translation for text node - domTranslationProcessor.deleteNode(textNode); - expect(textNode.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - - // translate element - - const div1 = document.createElement('div'); - div1.innerHTML = 'Hello world 2!'; - - domTranslationProcessor.addNode(div1); - await awaitTranslation(); - expect(div1.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); - - // reset translation for element - domTranslationProcessor.deleteNode(div1); - expect(div1.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + //delete element + domTranslationProcessor.deleteNode(div.childNodes[0]); + expect(domTranslationProcessor.isNodeStorageHas(div.childNodes[0])).toBe(false); }); test('updateNode should be call ones', async () => { @@ -176,11 +170,16 @@ describe('base usage', () => { // spy on the updateNode method const updateNodesSpy = vi.spyOn( - domTranslationProcessor as DomTranslationProcessor, + domTranslationProcessor as DomNodesTranslator, 'updateNode', ); - domTranslationProcessor.addNode(div); + // translate element + if (div instanceof Element) { + domTranslationProcessor.processNodesInElement(div, (node) => { + domTranslationProcessor.addNode(node); + }); + } await awaitTranslation(); // update element diff --git a/src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap b/src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap index 3a22169..719714b 100644 --- a/src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap +++ b/src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`base usage > transalate whole document 1`] = ` +exports[`base usage > translate whole document 1`] = ` " ***TRANSLATED***Demo page with challenges for DOM translator From 7000632e88ef0c5099c294fa155136ddbb68fc43 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 11 Apr 2025 22:33:14 +0200 Subject: [PATCH 028/313] chore: fix typos, improve code style --- src/LazyTranslator.ts | 67 +++++++++++++++++++----------------------- src/NodesTranslator.ts | 66 +++++++++++++++++++++-------------------- 2 files changed, 64 insertions(+), 69 deletions(-) diff --git a/src/LazyTranslator.ts b/src/LazyTranslator.ts index ebb2cc7..6ef985a 100644 --- a/src/LazyTranslator.ts +++ b/src/LazyTranslator.ts @@ -8,8 +8,8 @@ type IntersectionConfig = { threshold?: number; }; -function isIntersectableNode(node: Element) { - // return true for all element not +function isIntersectingNode(node: Element) { + // return true for all element not if (node.nodeName === 'OPTION') return false; return document.body.contains(node); @@ -20,33 +20,26 @@ function isIntersectableNode(node: Element) { * by default, the top-level document's viewport. */ export class LazyTranslator { - private translator: Translator; - private readonly isTranslatableNode: TranslatableNodePredicate; + private readonly intersectStorage = new WeakSet(); - private readonly itersectStorage = new WeakSet(); - - private itersectObserver: IntersectionObserver; + private intersectionObserver: IntersectionObserver; constructor( - isTranslatableNode: TranslatableNodePredicate, - transaltor: Translator, + private readonly isTranslatableNode: TranslatableNodePredicate, + private translator: Translator, intersectionConfig: IntersectionConfig = { root: null, rootMargin: '0px', threshold: 0, }, ) { - this.isTranslatableNode = isTranslatableNode; - - this.translator = transaltor; - - this.itersectObserver = new IntersectionObserver((entries, observer) => { + this.intersectionObserver = new IntersectionObserver((entries, observer) => { entries.forEach((entry) => { const node = entry.target; - if (!this.itersectStorage.has(node) || !entry.isIntersecting) return; + if (!this.intersectStorage.has(node) || !entry.isIntersecting) return; - this.itersectStorage.delete(node); + this.intersectStorage.delete(node); observer.unobserve(node); this.handlerIntersectNode(node); @@ -54,23 +47,6 @@ export class LazyTranslator { }, intersectionConfig); } - private handlerIntersectNode(node: Node) { - // Translate child text nodes and attributes of target node - // WARNING: we shall not touch inner nodes, because its may still not intersected - node.childNodes.forEach((node) => { - if (node instanceof Element || !this.isTranslatableNode(node)) { - return; - } - this.translator(node); - }); - } - - private handleElementByIntersectViewport(node: Element) { - if (this.itersectStorage.has(node)) return; - this.itersectStorage.add(node); - this.itersectObserver.observe(node); - } - public isLazilyTranslatable(node: Node) { // Lazy translate when own element intersect viewport // But translate at once if node have not parent (virtual node) or parent node is outside of body (utility tags like meta or title) @@ -79,11 +55,11 @@ export class LazyTranslator { const observableNode = node instanceof Attr ? node.ownerElement : node.parentElement; - // Ignore lazy translation for not intersectable nodes and translate it immediately + // Ignore lazy translation for not introspectable nodes and translate it immediately if ( isAttachedToDOM && observableNode !== null && - isIntersectableNode(observableNode) + isIntersectingNode(observableNode) ) { this.handleElementByIntersectViewport(observableNode); @@ -93,8 +69,25 @@ export class LazyTranslator { } public disableLazyTranslation(node: Element) { - this.itersectStorage.delete(node); + this.intersectStorage.delete(node); + + this.intersectionObserver.unobserve(node); + } - this.itersectObserver.unobserve(node); + private handlerIntersectNode(node: Node) { + // Translate child text nodes and attributes of target node + // WARNING: we shall not touch inner nodes, because its may still not intersected + node.childNodes.forEach((node) => { + if (node instanceof Element || !this.isTranslatableNode(node)) { + return; + } + this.translator(node); + }); + } + + private handleElementByIntersectViewport(node: Element) { + if (this.intersectStorage.has(node)) return; + this.intersectStorage.add(node); + this.intersectionObserver.observe(node); } } diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 65c8963..0a1a401 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,4 +1,4 @@ -import { DomTranslationProcessor } from './DomTranslationProcessor'; +import { DomNodesTranslator } from './DomTranslationProcessor'; import { LazyTranslator } from './LazyTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; import { NodeStorage } from './NodeStorage'; @@ -25,9 +25,11 @@ export type TranslatorInterface = (text: string, priority: number) => Promise(); + constructor(translateCallback: TranslatorInterface, config?: Config) { this.config = { ...config, @@ -37,7 +39,7 @@ export class NodesTranslator { config?.lazyTranslate !== undefined ? config?.lazyTranslate : true, }; - this.domTranslationProcessor = new DomTranslationProcessor( + this.domTranslationProcessor = new DomNodesTranslator( this.config.isTranslatableNode, new NodeStorage(), translateCallback, @@ -49,40 +51,12 @@ export class NodesTranslator { ); } - private addNode(node: Node) { - // handle all nodes contained within the element (text nodes and attributes of the current and nested elements) - - if (node instanceof Element) { - this.domTranslationProcessor.processNodesInElement(node, (node) => { - this.addNode(node); - }); - return; - } - - // if an element can't be translated later, translate it immediately - - if (this.config.lazyTranslate && this.lazyTranslator.isLazilyTranslatable(node)) { - return; - } - - this.domTranslationProcessor.addNode(node); - } - - private deleteNode(node: Node) { - this.domTranslationProcessor.deleteNode(node); - - if (node instanceof Element) { - this.lazyTranslator.disableLazyTranslation(node); - } - } - - private readonly observedNodesStorage = new Map(); public observe(node: Element) { if (this.observedNodesStorage.has(node)) { throw new Error('Node already under observe'); } - // Observe node and childs changes + // Observe node and children changes const observer = new XMutationObserver(); this.observedNodesStorage.set(node, observer); @@ -124,4 +98,32 @@ export class NodesTranslator { public getNodeData(node: Node) { return this.domTranslationProcessor.getOriginalNodeText(node); } + + private addNode(node: Node) { + // handle all nodes contained within the element (text nodes and attributes of the current and nested elements) + + if (node instanceof Element) { + this.domTranslationProcessor.processNodesInElement(node, (node) => { + this.addNode(node); + }); + return; + } + + // if an element can't be translated later, translate it immediately + + if (this.config.lazyTranslate && this.lazyTranslator.isLazilyTranslatable(node)) { + return; + } + + // translate + this.domTranslationProcessor.addNode(node); + } + + private deleteNode(node: Node) { + this.domTranslationProcessor.deleteNode(node); + + if (node instanceof Element) { + this.lazyTranslator.disableLazyTranslation(node); + } + } } From f2b6e47e0c44da30a005dbaf71e1150a75798bb2 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 11 Apr 2025 22:43:39 +0200 Subject: [PATCH 029/313] chore: rename file --- ...tionProcessor.ts => DomNodesTranslator.ts} | 0 src/NodesTranslator.ts | 2 +- ...sor.test.ts => DomNodesTranslator.test.ts} | 53 +++++++++---------- ...s.snap => DomNodesTranslator.test.ts.snap} | 0 4 files changed, 26 insertions(+), 29 deletions(-) rename src/{DomTranslationProcessor.ts => DomNodesTranslator.ts} (100%) rename src/__tests__/{DomTranslationProcessor.test.ts => DomNodesTranslator.test.ts} (74%) rename src/__tests__/__snapshots__/{DomTranslationProcessor.test.ts.snap => DomNodesTranslator.test.ts.snap} (100%) diff --git a/src/DomTranslationProcessor.ts b/src/DomNodesTranslator.ts similarity index 100% rename from src/DomTranslationProcessor.ts rename to src/DomNodesTranslator.ts diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 0a1a401..fd537c3 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,4 +1,4 @@ -import { DomNodesTranslator } from './DomTranslationProcessor'; +import { DomNodesTranslator } from './DomNodesTranslator'; import { LazyTranslator } from './LazyTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; import { NodeStorage } from './NodeStorage'; diff --git a/src/__tests__/DomTranslationProcessor.test.ts b/src/__tests__/DomNodesTranslator.test.ts similarity index 74% rename from src/__tests__/DomTranslationProcessor.test.ts rename to src/__tests__/DomNodesTranslator.test.ts index 170a75d..94c83be 100644 --- a/src/__tests__/DomTranslationProcessor.test.ts +++ b/src/__tests__/DomNodesTranslator.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'fs'; -import { DomNodesTranslator } from '../DomTranslationProcessor'; +import { DomNodesTranslator } from '../DomNodesTranslator'; import { NodeStorage } from '../NodeStorage'; import { awaitTranslation, @@ -13,7 +13,7 @@ import { const sample = readFileSync(__dirname + '/sample.html', 'utf8'); describe('base usage', () => { - let domTranslationProcessor: DomNodesTranslator; + let domNodesTranslator: DomNodesTranslator; let div: Element; const spy = vi.fn(async (node: Node) => { @@ -32,7 +32,7 @@ describe('base usage', () => { const isTranslatableNode = (node: Node) => node instanceof Text; - domTranslationProcessor = new DomNodesTranslator( + domNodesTranslator = new DomNodesTranslator( isTranslatableNode, new NodeStorage(), translator, @@ -45,12 +45,9 @@ describe('base usage', () => { // translate document if (document.documentElement instanceof Element) { - domTranslationProcessor.processNodesInElement( - document.documentElement, - (node) => { - domTranslationProcessor.addNode(node); - }, - ); + domNodesTranslator.processNodesInElement(document.documentElement, (node) => { + domNodesTranslator.addNode(node); + }); } await awaitTranslation(); @@ -58,7 +55,7 @@ describe('base usage', () => { // disable translation - domTranslationProcessor.deleteNode(document.documentElement); + domNodesTranslator.deleteNode(document.documentElement); expect(document.documentElement.outerHTML).toBe(parsedHTML); }); @@ -66,11 +63,11 @@ describe('base usage', () => { const originalText = 'Hello world!'; // translate - domTranslationProcessor.addNode(div.childNodes[0]); + domNodesTranslator.addNode(div.childNodes[0]); await awaitTranslation(); - expect(domTranslationProcessor.getOriginalNodeText(div.childNodes[0])).toEqual( + expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toEqual( expect.objectContaining({ originalText: originalText, }), @@ -81,7 +78,7 @@ describe('base usage', () => { div.innerHTML = ' '; // translate document - domTranslationProcessor.addNode(div.childNodes[0]); + domNodesTranslator.addNode(div.childNodes[0]); await awaitTranslation(); expect(div.childNodes[0].textContent).not.toMatch( @@ -94,7 +91,7 @@ describe('base usage', () => { div1.innerHTML = 'Hello world too!'; div.append(div1); - domTranslationProcessor.processNodesInElement(div, spy); + domNodesTranslator.processNodesInElement(div, spy); await awaitTranslation(); expect(spy).toHaveBeenCalledTimes(2); @@ -111,7 +108,7 @@ describe('base usage', () => { shadowElement.setAttribute('data-test', 'value'); shadowRoot.appendChild(shadowElement); - domTranslationProcessor.processNodesInElement(container, spy); + domNodesTranslator.processNodesInElement(container, spy); await awaitTranslation(); expect(spy).toHaveBeenCalledWith(shadowElement.firstChild); @@ -122,8 +119,8 @@ describe('base usage', () => { test('disable translation only for the target node', async () => { const handelTree = (node: Node) => { if (node instanceof Element) { - domTranslationProcessor.processNodesInElement(node, (node) => { - domTranslationProcessor.addNode(node); + domNodesTranslator.processNodesInElement(node, (node) => { + domNodesTranslator.addNode(node); }); } }; @@ -135,7 +132,7 @@ describe('base usage', () => { handelTree(div1); await awaitTranslation(); - domTranslationProcessor.deleteNode(div); + domNodesTranslator.deleteNode(div); // child node and target has not translated text expect(div1.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); @@ -145,7 +142,7 @@ describe('base usage', () => { handelTree(div); await awaitTranslation(); - domTranslationProcessor.deleteNode(div.childNodes[0], true); + domNodesTranslator.deleteNode(div.childNodes[0], true); expect(div.childNodes[0].textContent).not.toMatch( containsRegex(TRANSLATION_SYMBOL), @@ -155,14 +152,14 @@ describe('base usage', () => { }); test('isNodeStorageHas returns correct result', async () => { - domTranslationProcessor.addNode(div.childNodes[0]); + domNodesTranslator.addNode(div.childNodes[0]); await awaitTranslation(); - expect(domTranslationProcessor.isNodeStorageHas(div.childNodes[0])).toBe(true); + expect(domNodesTranslator.isNodeStorageHas(div.childNodes[0])).toBe(true); //delete element - domTranslationProcessor.deleteNode(div.childNodes[0]); - expect(domTranslationProcessor.isNodeStorageHas(div.childNodes[0])).toBe(false); + domNodesTranslator.deleteNode(div.childNodes[0]); + expect(domNodesTranslator.isNodeStorageHas(div.childNodes[0])).toBe(false); }); test('updateNode should be call ones', async () => { @@ -170,14 +167,14 @@ describe('base usage', () => { // spy on the updateNode method const updateNodesSpy = vi.spyOn( - domTranslationProcessor as DomNodesTranslator, + domNodesTranslator as DomNodesTranslator, 'updateNode', ); // translate element if (div instanceof Element) { - domTranslationProcessor.processNodesInElement(div, (node) => { - domTranslationProcessor.addNode(node); + domNodesTranslator.processNodesInElement(div, (node) => { + domNodesTranslator.addNode(node); }); } await awaitTranslation(); @@ -186,10 +183,10 @@ describe('base usage', () => { const newText = 'Goodbye world!'; div.innerHTML = newText; - domTranslationProcessor.addNode(div.childNodes[0]); + domNodesTranslator.addNode(div.childNodes[0]); await awaitTranslation(); - domTranslationProcessor.updateNode(div.childNodes[0]); + domNodesTranslator.updateNode(div.childNodes[0]); await awaitTranslation(); expect(div.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); diff --git a/src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap b/src/__tests__/__snapshots__/DomNodesTranslator.test.ts.snap similarity index 100% rename from src/__tests__/__snapshots__/DomTranslationProcessor.test.ts.snap rename to src/__tests__/__snapshots__/DomNodesTranslator.test.ts.snap From 187b7c17bdad46714b05f3c32249a79feefd6b1e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 11 Apr 2025 22:54:25 +0200 Subject: [PATCH 030/313] refactor: move type --- src/DomNodesTranslator.ts | 3 +-- src/LazyTranslator.ts | 2 +- src/NodesTranslator.ts | 6 ++++-- src/types.ts | 1 - 4 files changed, 6 insertions(+), 6 deletions(-) delete mode 100644 src/types.ts diff --git a/src/DomNodesTranslator.ts b/src/DomNodesTranslator.ts index 944c106..2cf65eb 100644 --- a/src/DomNodesTranslator.ts +++ b/src/DomNodesTranslator.ts @@ -1,8 +1,7 @@ import { NodeStorage } from './NodeStorage'; -import { TranslatableNodePredicate } from './types'; import { isInViewport } from './utils/isInViewport'; import { nodeExplore } from './utils/nodeExplore'; -import { TranslatorInterface } from '.'; +import { TranslatableNodePredicate, TranslatorInterface } from '.'; /** * Class DomTranslationProcessor responsible for translating DOM nodes diff --git a/src/LazyTranslator.ts b/src/LazyTranslator.ts index 6ef985a..b766aab 100644 --- a/src/LazyTranslator.ts +++ b/src/LazyTranslator.ts @@ -1,4 +1,4 @@ -import { TranslatableNodePredicate } from './types'; +import { TranslatableNodePredicate } from '.'; type Translator = (node: Node) => void; diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index fd537c3..9b8ecb5 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -4,13 +4,15 @@ import { XMutationObserver } from './lib/XMutationObserver'; import { NodeStorage } from './NodeStorage'; import { configureTranslatableNodePredicate } from './utils/nodes'; +export type TranslatableNodePredicate = (node: Node) => boolean; + export interface InnerConfig { - isTranslatableNode: (node: Node) => boolean; + isTranslatableNode: TranslatableNodePredicate; lazyTranslate: boolean; } export interface Config { - isTranslatableNode?: (node: Node) => boolean; + isTranslatableNode?: TranslatableNodePredicate; lazyTranslate?: boolean; } diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 44a84a0..0000000 --- a/src/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type TranslatableNodePredicate = (node: Node) => boolean; From 9e00a7da04624dd349c1f2557d90b1d225e15fc9 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 11 Apr 2025 22:57:31 +0200 Subject: [PATCH 031/313] chore: fix typo, improve style --- src/__tests__/LazyTranslator.test.ts | 22 +++++++++++----------- src/__tests__/NodeStorage.test.ts | 23 ++++++++++++++--------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/__tests__/LazyTranslator.test.ts b/src/__tests__/LazyTranslator.test.ts index eb55ec2..ee033c3 100644 --- a/src/__tests__/LazyTranslator.test.ts +++ b/src/__tests__/LazyTranslator.test.ts @@ -23,9 +23,9 @@ describe('base usage', () => { }); test('translate element at intersection', async () => { - const lazyTraslator = new LazyTranslator(isTranslatableNode, translator); + const lazyTranslator = new LazyTranslator(isTranslatableNode, translator); - const isLazyTranslate = lazyTraslator.isLazilyTranslatable(textNode); + const isLazyTranslate = lazyTranslator.isLazilyTranslatable(textNode); await awaitTranslation(); @@ -39,11 +39,11 @@ describe('base usage', () => { }); test('translate node that intersect the custom ancestor', async () => { - const lazyTraslator = new LazyTranslator(isTranslatableNode, translator, { + const lazyTranslator = new LazyTranslator(isTranslatableNode, translator, { root: divElement, }); - const isLazyTranslate = lazyTraslator.isLazilyTranslatable(textNode); + const isLazyTranslate = lazyTranslator.isLazilyTranslatable(textNode); await awaitTranslation(); @@ -57,11 +57,11 @@ describe('base usage', () => { }); test('not translate nodes that not intersected', async () => { - const textNode = document.createTextNode('Hello World!'); + const lazyTranslator = new LazyTranslator(isTranslatableNode, translator); - const lazyTraslator = new LazyTranslator(isTranslatableNode, translator); + const textNode = document.createTextNode('Hello World!'); - const isLazyTranslate = lazyTraslator.isLazilyTranslatable(textNode); + const isLazyTranslate = lazyTranslator.isLazilyTranslatable(textNode); await awaitTranslation(); @@ -72,13 +72,13 @@ describe('base usage', () => { }); test('not translate node that not intersect the custom ancestor', async () => { - const textNode = document.createTextNode('Hello World!'); - - const lazyTraslator = new LazyTranslator(isTranslatableNode, translator, { + const lazyTranslator = new LazyTranslator(isTranslatableNode, translator, { root: divElement, }); - const isLazyTranslate = lazyTraslator.isLazilyTranslatable(textNode); + const textNode = document.createTextNode('Hello World!'); + + const isLazyTranslate = lazyTranslator.isLazilyTranslatable(textNode); await awaitTranslation(); diff --git a/src/__tests__/NodeStorage.test.ts b/src/__tests__/NodeStorage.test.ts index 8c26f22..721e233 100644 --- a/src/__tests__/NodeStorage.test.ts +++ b/src/__tests__/NodeStorage.test.ts @@ -1,24 +1,25 @@ import { NodeStorage } from '../NodeStorage'; describe('NodeStorage', () => { - let nodeStorage: NodeStorage; let div: Node; let div1: Node; beforeEach(() => { - nodeStorage = new NodeStorage(); - div = document.createElement('div'); div.textContent = 'Hello world!'; div1 = document.createElement('div'); }); test('return correct value for a node that is not added', () => { + const nodeStorage = new NodeStorage(); + expect(nodeStorage.has(div)).toBe(false); expect(nodeStorage.get(div)).toBeNull(); }); test('add a node to storage', () => { + const nodeStorage = new NodeStorage(); + nodeStorage.add(div, 1); expect(nodeStorage.has(div)).toBe(true); @@ -34,6 +35,8 @@ describe('NodeStorage', () => { }); test('can not add the same node twice', () => { + const nodeStorage = new NodeStorage(); + nodeStorage.add(div, 1); nodeStorage.add(div, 1); @@ -44,7 +47,9 @@ describe('NodeStorage', () => { ); }); - test('increase id counter when adding new node', () => { + test('increase id counter after add new node', () => { + const nodeStorage = new NodeStorage(); + nodeStorage.add(div, 1); nodeStorage.add(div1, 1); @@ -55,7 +60,9 @@ describe('NodeStorage', () => { ); }); - test('increase updateId when updating a node', () => { + test('increase updateId after update a node', () => { + const nodeStorage = new NodeStorage(); + nodeStorage.add(div, 1); nodeStorage.update(div); @@ -67,13 +74,11 @@ describe('NodeStorage', () => { }); test('remove node from storage', () => { + const nodeStorage = new NodeStorage(); + nodeStorage.add(div, 1); nodeStorage.delete(div); expect(nodeStorage.get(div)).toBeNull(); }); - - test('not throw if deleting a non-existent node', () => { - expect(() => nodeStorage.delete(div)).not.toThrow(); - }); }); From 05a0071bb65e1a2ff64b92ac2d7300d1f9716f55 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 11 Apr 2025 23:12:44 +0200 Subject: [PATCH 032/313] chore: short descriptions --- src/DomNodesTranslator.ts | 2 +- src/LazyTranslator.ts | 3 +-- src/NodeStorage.ts | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/DomNodesTranslator.ts b/src/DomNodesTranslator.ts index 2cf65eb..166bde3 100644 --- a/src/DomNodesTranslator.ts +++ b/src/DomNodesTranslator.ts @@ -4,7 +4,7 @@ import { nodeExplore } from './utils/nodeExplore'; import { TranslatableNodePredicate, TranslatorInterface } from '.'; /** - * Class DomTranslationProcessor responsible for translating DOM nodes + * Class DomNodesTranslator responsible for translating DOM nodes */ export class DomNodesTranslator { constructor( diff --git a/src/LazyTranslator.ts b/src/LazyTranslator.ts index b766aab..b42bde2 100644 --- a/src/LazyTranslator.ts +++ b/src/LazyTranslator.ts @@ -16,8 +16,7 @@ function isIntersectingNode(node: Element) { } /** - * The class provides a way to translate only those elements that intersect with an ancestor element, - * by default, the top-level document's viewport. + * Translates nodes only if they intersect the viewport */ export class LazyTranslator { private readonly intersectStorage = new WeakSet(); diff --git a/src/NodeStorage.ts b/src/NodeStorage.ts index 1df1787..f1ff08e 100644 --- a/src/NodeStorage.ts +++ b/src/NodeStorage.ts @@ -27,7 +27,7 @@ export interface NodeData { } /** - * The NodeStorage class encapsulates node storage, manages node metadata + * The NodeStorage class store nodes data */ export class NodeStorage { From f405b04b878b608803143889ef66276c63778974 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 15 Apr 2025 22:30:15 +0200 Subject: [PATCH 033/313] refactor: move method to function --- src/DomNodesTranslator.ts | 63 ++++++++++-------------- src/NodesTranslator.ts | 10 ++-- src/__tests__/DomNodesTranslator.test.ts | 44 +++-------------- 3 files changed, 39 insertions(+), 78 deletions(-) diff --git a/src/DomNodesTranslator.ts b/src/DomNodesTranslator.ts index 166bde3..5dfce34 100644 --- a/src/DomNodesTranslator.ts +++ b/src/DomNodesTranslator.ts @@ -3,6 +3,30 @@ import { isInViewport } from './utils/isInViewport'; import { nodeExplore } from './utils/nodeExplore'; import { TranslatableNodePredicate, TranslatorInterface } from '.'; +/** + * Handle all translatable nodes from element + * Element, Attr, Text + */ +export function handleTree(node: Element, callback: (node: Node) => void) { + nodeExplore(node, NodeFilter.SHOW_ALL, true, (node) => { + callback(node); + + if (node instanceof Element) { + // Handle nodes from opened shadow DOM + if (node.shadowRoot !== null) { + for (const child of Array.from(node.shadowRoot.children)) { + handleTree(child, callback); + } + } + + // Handle attributes of element + for (const attribute of Object.values(node.attributes)) { + callback(attribute); + } + } + }); +} + /** * Class DomNodesTranslator responsible for translating DOM nodes */ @@ -40,7 +64,7 @@ export class DomNodesTranslator { public deleteNode(node: Node, onlyTarget = false) { if (node instanceof Element && !onlyTarget) { // Delete all attributes and inner nodes - this.handleTree(node, (node) => { + handleTree(node, (node) => { this.deleteNode(node, true); }); } @@ -57,43 +81,6 @@ export class DomNodesTranslator { this.translateNode(node); } - /** - * processNodesInElement execute callback only for translatable nodes, recursively traversing the element - */ - public processNodesInElement(element: Element, callback: (node: Node) => void) { - this.handleTree(element, (node) => { - if (node instanceof Element) return; - - if (this.isTranslatableNode(node)) { - callback(node); - } - }); - } - - /** - * Handle all translatable nodes from element - * Element, Attr, Text - */ - private handleTree(node: Element, callback: (node: Node) => void) { - nodeExplore(node, NodeFilter.SHOW_ALL, true, (node) => { - callback(node); - - if (node instanceof Element) { - // Handle nodes from opened shadow DOM - if (node.shadowRoot !== null) { - for (const child of Array.from(node.shadowRoot.children)) { - this.handleTree(child, callback); - } - } - - // Handle attributes of element - for (const attribute of Object.values(node.attributes)) { - callback(attribute); - } - } - }); - } - /** * Calculate node priority for translate, the bigger number the importance text */ diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 9b8ecb5..c9d4a58 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,4 +1,4 @@ -import { DomNodesTranslator } from './DomNodesTranslator'; +import { DomNodesTranslator, handleTree } from './DomNodesTranslator'; import { LazyTranslator } from './LazyTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; import { NodeStorage } from './NodeStorage'; @@ -105,8 +105,12 @@ export class NodesTranslator { // handle all nodes contained within the element (text nodes and attributes of the current and nested elements) if (node instanceof Element) { - this.domTranslationProcessor.processNodesInElement(node, (node) => { - this.addNode(node); + handleTree(node, (node) => { + if (node instanceof Element) return; + + if (this.config.isTranslatableNode(node)) { + this.addNode(node); + } }); return; } diff --git a/src/__tests__/DomNodesTranslator.test.ts b/src/__tests__/DomNodesTranslator.test.ts index 94c83be..52ae8ec 100644 --- a/src/__tests__/DomNodesTranslator.test.ts +++ b/src/__tests__/DomNodesTranslator.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'fs'; -import { DomNodesTranslator } from '../DomNodesTranslator'; +import { DomNodesTranslator, handleTree } from '../DomNodesTranslator'; import { NodeStorage } from '../NodeStorage'; import { awaitTranslation, @@ -45,7 +45,7 @@ describe('base usage', () => { // translate document if (document.documentElement instanceof Element) { - domNodesTranslator.processNodesInElement(document.documentElement, (node) => { + handleTree(document.documentElement, (node) => { domNodesTranslator.addNode(node); }); } @@ -86,40 +86,10 @@ describe('base usage', () => { ); }); - test('process the element tree', async () => { - const div1 = document.createElement('div'); - div1.innerHTML = 'Hello world too!'; - div.append(div1); - - domNodesTranslator.processNodesInElement(div, spy); - await awaitTranslation(); - - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith(div1.childNodes[0]); - expect(spy).not.toHaveBeenCalledWith(div1); - expect(div1.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); - }); - - test('process the element tree with shadow dom', async () => { - const container = document.createElement('div'); - const shadowRoot = container.attachShadow({ mode: 'open' }); - const shadowElement = document.createElement('p'); - shadowElement.textContent = 'Shadow text'; - shadowElement.setAttribute('data-test', 'value'); - shadowRoot.appendChild(shadowElement); - - domNodesTranslator.processNodesInElement(container, spy); - await awaitTranslation(); - - expect(spy).toHaveBeenCalledWith(shadowElement.firstChild); - expect(spy).toBeCalledTimes(1); - expect(shadowElement.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); - }); - test('disable translation only for the target node', async () => { - const handelTree = (node: Node) => { + const handelTree1 = (node: Node) => { if (node instanceof Element) { - domNodesTranslator.processNodesInElement(node, (node) => { + handleTree(node, (node) => { domNodesTranslator.addNode(node); }); } @@ -129,7 +99,7 @@ describe('base usage', () => { div.append(div1); // delete the target element and its nested items - handelTree(div1); + handelTree1(div1); await awaitTranslation(); domNodesTranslator.deleteNode(div); @@ -139,7 +109,7 @@ describe('base usage', () => { expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); // delete translation only for the target element - handelTree(div); + handelTree1(div); await awaitTranslation(); domNodesTranslator.deleteNode(div.childNodes[0], true); @@ -173,7 +143,7 @@ describe('base usage', () => { // translate element if (div instanceof Element) { - domNodesTranslator.processNodesInElement(div, (node) => { + handleTree(div, (node) => { domNodesTranslator.addNode(node); }); } From be135af7a547ab71d62f5ac88a0a8df7da8269b0 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 15 Apr 2025 23:59:15 +0200 Subject: [PATCH 034/313] refactor: move responsible --- src/LazyTranslator.ts | 70 +++++++++------------------- src/NodesTranslator.ts | 33 ++++++++----- src/__tests__/LazyTranslator.test.ts | 46 +++++++++--------- src/utils/isIntersectingNode.ts | 6 +++ 4 files changed, 73 insertions(+), 82 deletions(-) create mode 100644 src/utils/isIntersectingNode.ts diff --git a/src/LazyTranslator.ts b/src/LazyTranslator.ts index b42bde2..2fdbc03 100644 --- a/src/LazyTranslator.ts +++ b/src/LazyTranslator.ts @@ -1,37 +1,30 @@ import { TranslatableNodePredicate } from '.'; -type Translator = (node: Node) => void; - type IntersectionConfig = { root?: null | Element; rootMargin?: string; threshold?: number; }; -function isIntersectingNode(node: Element) { - // return true for all element not - if (node.nodeName === 'OPTION') return false; - - return document.body.contains(node); -} +type LazyTranslatorConfig = { + isTranslatableNode: TranslatableNodePredicate; + translator: (node: Node) => void; + intersectionConfig?: IntersectionConfig; +}; /** * Translates nodes only if they intersect the viewport */ + export class LazyTranslator { private readonly intersectStorage = new WeakSet(); - private intersectionObserver: IntersectionObserver; - constructor( - private readonly isTranslatableNode: TranslatableNodePredicate, - private translator: Translator, - intersectionConfig: IntersectionConfig = { - root: null, - rootMargin: '0px', - threshold: 0, - }, - ) { + private readonly config: LazyTranslatorConfig; + + constructor(config: LazyTranslatorConfig) { + this.config = config; + this.intersectionObserver = new IntersectionObserver((entries, observer) => { entries.forEach((entry) => { const node = entry.target; @@ -43,50 +36,29 @@ export class LazyTranslator { this.handlerIntersectNode(node); }); - }, intersectionConfig); + }, this.config.intersectionConfig); } - public isLazilyTranslatable(node: Node) { - // Lazy translate when own element intersect viewport - // But translate at once if node have not parent (virtual node) or parent node is outside of body (utility tags like meta or title) - - const isAttachedToDOM = node.getRootNode() !== node; - const observableNode = - node instanceof Attr ? node.ownerElement : node.parentElement; - - // Ignore lazy translation for not introspectable nodes and translate it immediately - if ( - isAttachedToDOM && - observableNode !== null && - isIntersectingNode(observableNode) - ) { - this.handleElementByIntersectViewport(observableNode); - - return true; - } - return false; - } - - public disableLazyTranslation(node: Element) { + public stopObserving(node: Element) { this.intersectStorage.delete(node); this.intersectionObserver.unobserve(node); } + public startObserving(node: Element) { + if (this.intersectStorage.has(node)) return; + this.intersectStorage.add(node); + this.intersectionObserver.observe(node); + } + private handlerIntersectNode(node: Node) { // Translate child text nodes and attributes of target node // WARNING: we shall not touch inner nodes, because its may still not intersected node.childNodes.forEach((node) => { - if (node instanceof Element || !this.isTranslatableNode(node)) { + if (node instanceof Element || !this.config.isTranslatableNode(node)) { return; } - this.translator(node); + this.config.translator(node); }); } - - private handleElementByIntersectViewport(node: Element) { - if (this.intersectStorage.has(node)) return; - this.intersectStorage.add(node); - this.intersectionObserver.observe(node); - } } diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index c9d4a58..aeedc08 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -2,6 +2,7 @@ import { DomNodesTranslator, handleTree } from './DomNodesTranslator'; import { LazyTranslator } from './LazyTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; import { NodeStorage } from './NodeStorage'; +import { isIntersectingNode } from './utils/isIntersectingNode'; import { configureTranslatableNodePredicate } from './utils/nodes'; export type TranslatableNodePredicate = (node: Node) => boolean; @@ -47,10 +48,10 @@ export class NodesTranslator { translateCallback, ); - this.lazyTranslator = new LazyTranslator( - this.config.isTranslatableNode, - this.domTranslationProcessor.addNode, - ); + this.lazyTranslator = new LazyTranslator({ + isTranslatableNode: this.config.isTranslatableNode, + translator: this.domTranslationProcessor.addNode, + }); } public observe(node: Element) { @@ -103,7 +104,6 @@ export class NodesTranslator { private addNode(node: Node) { // handle all nodes contained within the element (text nodes and attributes of the current and nested elements) - if (node instanceof Element) { handleTree(node, (node) => { if (node instanceof Element) return; @@ -115,13 +115,24 @@ export class NodesTranslator { return; } - // if an element can't be translated later, translate it immediately - - if (this.config.lazyTranslate && this.lazyTranslator.isLazilyTranslatable(node)) { - return; + // Ignore lazy translation for not introspectable nodes and translate it immediately + if (this.config.lazyTranslate) { + // Lazy translate when own element intersect viewport + // But translate at once if node have not parent (virtual node) or parent node is outside of body (utility tags like meta or title) + const isAttachedToDOM = node.getRootNode() !== node; + const observableNode = + node instanceof Attr ? node.ownerElement : node.parentElement; + + if ( + isAttachedToDOM && + observableNode !== null && + isIntersectingNode(observableNode) + ) { + this.lazyTranslator.startObserving(observableNode); + return; + } } - // translate this.domTranslationProcessor.addNode(node); } @@ -129,7 +140,7 @@ export class NodesTranslator { this.domTranslationProcessor.deleteNode(node); if (node instanceof Element) { - this.lazyTranslator.disableLazyTranslation(node); + this.lazyTranslator.stopObserving(node); } } } diff --git a/src/__tests__/LazyTranslator.test.ts b/src/__tests__/LazyTranslator.test.ts index ee033c3..8a1c597 100644 --- a/src/__tests__/LazyTranslator.test.ts +++ b/src/__tests__/LazyTranslator.test.ts @@ -23,10 +23,9 @@ describe('base usage', () => { }); test('translate element at intersection', async () => { - const lazyTranslator = new LazyTranslator(isTranslatableNode, translator); - - const isLazyTranslate = lazyTranslator.isLazilyTranslatable(textNode); + const lazyTranslator = new LazyTranslator({ isTranslatableNode, translator }); + lazyTranslator.startObserving(divElement); await awaitTranslation(); // The mock function was called ones @@ -34,17 +33,18 @@ describe('base usage', () => { expect(translator).toHaveBeenCalledWith(textNode); // the node translate lazy - expect(isLazyTranslate).toBe(true); expect(textNode.textContent).toMatchObject(containsRegex(TRANSLATION_SYMBOL)); }); test('translate node that intersect the custom ancestor', async () => { - const lazyTranslator = new LazyTranslator(isTranslatableNode, translator, { - root: divElement, + const lazyTranslator = new LazyTranslator({ + isTranslatableNode, + translator, + intersectionConfig: { + root: divElement, + }, }); - - const isLazyTranslate = lazyTranslator.isLazilyTranslatable(textNode); - + lazyTranslator.startObserving(divElement); await awaitTranslation(); // The mock function was called ones @@ -52,36 +52,38 @@ describe('base usage', () => { expect(translator).toHaveBeenCalledWith(textNode); // the node translate lazy - expect(isLazyTranslate).toBe(true); expect(textNode.textContent).toMatchObject(containsRegex(TRANSLATION_SYMBOL)); }); test('not translate nodes that not intersected', async () => { - const lazyTranslator = new LazyTranslator(isTranslatableNode, translator); - - const textNode = document.createTextNode('Hello World!'); + const lazyTranslator = new LazyTranslator({ isTranslatableNode, translator }); - const isLazyTranslate = lazyTranslator.isLazilyTranslatable(textNode); + const newDivElement = document.createElement('div'); + lazyTranslator.startObserving(newDivElement); await awaitTranslation(); // The mock function was not called expect(translator.mock.calls).toHaveLength(0); - - expect(isLazyTranslate).toBe(false); + expect(newDivElement.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); test('not translate node that not intersect the custom ancestor', async () => { - const lazyTranslator = new LazyTranslator(isTranslatableNode, translator, { - root: divElement, + const divElement = document.createElement('div'); + const lazyTranslator = new LazyTranslator({ + isTranslatableNode, + translator, + intersectionConfig: { + root: divElement, + }, }); - const textNode = document.createTextNode('Hello World!'); - - const isLazyTranslate = lazyTranslator.isLazilyTranslatable(textNode); + const newDivElement = document.createElement('div'); + lazyTranslator.startObserving(newDivElement); await awaitTranslation(); - expect(isLazyTranslate).toBe(false); + expect(translator.mock.calls).toHaveLength(0); + expect(newDivElement.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); }); diff --git a/src/utils/isIntersectingNode.ts b/src/utils/isIntersectingNode.ts new file mode 100644 index 0000000..f4b31b8 --- /dev/null +++ b/src/utils/isIntersectingNode.ts @@ -0,0 +1,6 @@ +export function isIntersectingNode(node: Element) { + // return true for all element not + if (node.nodeName === 'OPTION') return false; + + return document.body.contains(node); +} From 7c464e5b0e119b8e1d4a1ca68582030b34efb3bd Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 16 Apr 2025 00:01:45 +0200 Subject: [PATCH 035/313] refactor: move function --- src/DomNodesTranslator.ts | 26 +----------------------- src/NodesTranslator.ts | 3 ++- src/__tests__/DomNodesTranslator.test.ts | 3 ++- src/utils/handleTree.ts | 26 ++++++++++++++++++++++++ 4 files changed, 31 insertions(+), 27 deletions(-) create mode 100644 src/utils/handleTree.ts diff --git a/src/DomNodesTranslator.ts b/src/DomNodesTranslator.ts index 5dfce34..f1b4f3e 100644 --- a/src/DomNodesTranslator.ts +++ b/src/DomNodesTranslator.ts @@ -1,32 +1,8 @@ import { NodeStorage } from './NodeStorage'; +import { handleTree } from './utils/handleTree'; import { isInViewport } from './utils/isInViewport'; -import { nodeExplore } from './utils/nodeExplore'; import { TranslatableNodePredicate, TranslatorInterface } from '.'; -/** - * Handle all translatable nodes from element - * Element, Attr, Text - */ -export function handleTree(node: Element, callback: (node: Node) => void) { - nodeExplore(node, NodeFilter.SHOW_ALL, true, (node) => { - callback(node); - - if (node instanceof Element) { - // Handle nodes from opened shadow DOM - if (node.shadowRoot !== null) { - for (const child of Array.from(node.shadowRoot.children)) { - handleTree(child, callback); - } - } - - // Handle attributes of element - for (const attribute of Object.values(node.attributes)) { - callback(attribute); - } - } - }); -} - /** * Class DomNodesTranslator responsible for translating DOM nodes */ diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index aeedc08..7e37b25 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,7 +1,8 @@ -import { DomNodesTranslator, handleTree } from './DomNodesTranslator'; +import { DomNodesTranslator } from './DomNodesTranslator'; import { LazyTranslator } from './LazyTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; import { NodeStorage } from './NodeStorage'; +import { handleTree } from './utils/handleTree'; import { isIntersectingNode } from './utils/isIntersectingNode'; import { configureTranslatableNodePredicate } from './utils/nodes'; diff --git a/src/__tests__/DomNodesTranslator.test.ts b/src/__tests__/DomNodesTranslator.test.ts index 52ae8ec..7d66333 100644 --- a/src/__tests__/DomNodesTranslator.test.ts +++ b/src/__tests__/DomNodesTranslator.test.ts @@ -1,7 +1,8 @@ import { readFileSync } from 'fs'; -import { DomNodesTranslator, handleTree } from '../DomNodesTranslator'; +import { DomNodesTranslator } from '../DomNodesTranslator'; import { NodeStorage } from '../NodeStorage'; +import { handleTree } from '../utils/handleTree'; import { awaitTranslation, containsRegex, diff --git a/src/utils/handleTree.ts b/src/utils/handleTree.ts new file mode 100644 index 0000000..d2915da --- /dev/null +++ b/src/utils/handleTree.ts @@ -0,0 +1,26 @@ +import { nodeExplore } from './nodeExplore'; + +/** + * Handle all translatable nodes from element + * Element, Attr, Text + */ + +export function handleTree(node: Element, callback: (node: Node) => void) { + nodeExplore(node, NodeFilter.SHOW_ALL, true, (node) => { + callback(node); + + if (node instanceof Element) { + // Handle nodes from opened shadow DOM + if (node.shadowRoot !== null) { + for (const child of Array.from(node.shadowRoot.children)) { + handleTree(child, callback); + } + } + + // Handle attributes of element + for (const attribute of Object.values(node.attributes)) { + callback(attribute); + } + } + }); +} From 6ae428a13bf5b368f9e55d24208da394b407cf5b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 16 Apr 2025 22:14:51 +0200 Subject: [PATCH 036/313] refactor: create wrapper --- src/DomTranslationManager.ts | 89 ++++++++++++++++++++++++++++++++++++ src/NodesTranslator.ts | 87 ++++++++++------------------------- 2 files changed, 113 insertions(+), 63 deletions(-) create mode 100644 src/DomTranslationManager.ts diff --git a/src/DomTranslationManager.ts b/src/DomTranslationManager.ts new file mode 100644 index 0000000..bbd3b58 --- /dev/null +++ b/src/DomTranslationManager.ts @@ -0,0 +1,89 @@ +import { DomNodesTranslator } from './DomNodesTranslator'; +import { LazyTranslator } from './LazyTranslator'; +import { InnerConfig } from './NodesTranslator'; +import { handleTree } from './utils/handleTree'; +import { isIntersectingNode } from './utils/isIntersectingNode'; + +type TranslationManagerConfig = { + config: InnerConfig; + domTranslationProcessor: DomNodesTranslator; + lazyTranslator: LazyTranslator; +}; + +/** + * Class choose translation strategy: lazy or immediate. + */ +export class Translation { + private readonly config: InnerConfig; + private readonly domTranslationProcessor: DomNodesTranslator; + private readonly lazyTranslator: LazyTranslator; + + constructor({ + config, + domTranslationProcessor, + lazyTranslator, + }: TranslationManagerConfig) { + this.config = config; + this.domTranslationProcessor = domTranslationProcessor; + this.lazyTranslator = lazyTranslator; + } + + public getNodeData(node: Node) { + return this.domTranslationProcessor.getOriginalNodeText(node); + } + + public updateNode(node: Node) { + this.domTranslationProcessor.updateNode(node); + } + + public isNodeStorageHas(node: Node) { + return this.domTranslationProcessor.has(node); + } + + public addNode(node: Node) { + // handle all nodes contained within the element (text nodes and attributes of the current and nested elements) + if (node instanceof Element) { + handleTree(node, (node) => { + if (node instanceof Element) return; + + if (this.config.isTranslatableNode(node)) { + this.addNode(node); + } + }); + return; + } + + // Ignore lazy translation for non-intersecting nodes and translate it immediately + if (this.config.lazyTranslate && this.tryLazyTranslate(node)) { + return; + } + + this.domTranslationProcessor.addNode(node); + } + + public deleteNode(node: Node) { + this.domTranslationProcessor.deleteNode(node); + + if (node instanceof Element) { + this.lazyTranslator.stopObserving(node); + } + } + + private tryLazyTranslate(node: Node) { + // Lazy translate when own element intersect viewport + // But translate at once if node have not parent (virtual node) or parent node is outside of body (utility tags like meta or title) + const isAttachedToDOM = node.getRootNode() !== node; + const observableNode = + node instanceof Attr ? node.ownerElement : node.parentElement; + + if ( + isAttachedToDOM && + observableNode !== null && + isIntersectingNode(observableNode) + ) { + this.lazyTranslator.startObserving(observableNode); + return true; + } + return false; + } +} diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 7e37b25..263d6de 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,9 +1,7 @@ import { DomNodesTranslator } from './DomNodesTranslator'; +import { Translation } from './DomTranslationManager'; import { LazyTranslator } from './LazyTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; -import { NodeStorage } from './NodeStorage'; -import { handleTree } from './utils/handleTree'; -import { isIntersectingNode } from './utils/isIntersectingNode'; import { configureTranslatableNodePredicate } from './utils/nodes'; export type TranslatableNodePredicate = (node: Node) => boolean; @@ -29,8 +27,7 @@ export type TranslatorInterface = (text: string, priority: number) => Promise(); @@ -43,15 +40,21 @@ export class NodesTranslator { config?.lazyTranslate !== undefined ? config?.lazyTranslate : true, }; - this.domTranslationProcessor = new DomNodesTranslator( + const domTranslationProcessor = new DomNodesTranslator( this.config.isTranslatableNode, - new NodeStorage(), + // new NodeStorage(), translateCallback, ); - this.lazyTranslator = new LazyTranslator({ + const lazyTranslator = new LazyTranslator({ isTranslatableNode: this.config.isTranslatableNode, - translator: this.domTranslationProcessor.addNode, + translator: domTranslationProcessor.addNode, + }); + + this.translator = new Translation({ + config: this.config, + domTranslationProcessor, + lazyTranslator, }); } @@ -64,10 +67,14 @@ export class NodesTranslator { const observer = new XMutationObserver(); this.observedNodesStorage.set(node, observer); - observer.addHandler('elementAdded', ({ target }) => this.addNode(target)); - observer.addHandler('elementRemoved', ({ target }) => this.deleteNode(target)); + observer.addHandler('elementAdded', ({ target }) => + this.translator.addNode(target), + ); + observer.addHandler('elementRemoved', ({ target }) => + this.translator.deleteNode(target), + ); observer.addHandler('characterData', ({ target }) => { - this.domTranslationProcessor.updateNode(target); + this.translator.updateNode(target); }); observer.addHandler('changeAttribute', ({ target, attributeName }) => { if (attributeName === undefined || attributeName === null) return; @@ -78,15 +85,15 @@ export class NodesTranslator { if (attribute === null) return; // NOTE: If need delete untracked nodes, we should keep relates like Element -> attributes - if (!this.domTranslationProcessor.isNodeStorageHas(attribute)) { - this.addNode(attribute); + if (!this.translator.isNodeStorageHas(attribute)) { + this.translator.addNode(attribute); } else { - this.domTranslationProcessor.updateNode(attribute); + this.translator.updateNode(attribute); } }); observer.observe(node); - this.addNode(node); + this.translator.addNode(node); } public unobserve(node: Element) { @@ -94,54 +101,8 @@ export class NodesTranslator { throw new Error('Node is not under observe'); } - this.deleteNode(node); + this.translator.deleteNode(node); this.observedNodesStorage.get(node)?.disconnect(); this.observedNodesStorage.delete(node); } - - public getNodeData(node: Node) { - return this.domTranslationProcessor.getOriginalNodeText(node); - } - - private addNode(node: Node) { - // handle all nodes contained within the element (text nodes and attributes of the current and nested elements) - if (node instanceof Element) { - handleTree(node, (node) => { - if (node instanceof Element) return; - - if (this.config.isTranslatableNode(node)) { - this.addNode(node); - } - }); - return; - } - - // Ignore lazy translation for not introspectable nodes and translate it immediately - if (this.config.lazyTranslate) { - // Lazy translate when own element intersect viewport - // But translate at once if node have not parent (virtual node) or parent node is outside of body (utility tags like meta or title) - const isAttachedToDOM = node.getRootNode() !== node; - const observableNode = - node instanceof Attr ? node.ownerElement : node.parentElement; - - if ( - isAttachedToDOM && - observableNode !== null && - isIntersectingNode(observableNode) - ) { - this.lazyTranslator.startObserving(observableNode); - return; - } - } - - this.domTranslationProcessor.addNode(node); - } - - private deleteNode(node: Node) { - this.domTranslationProcessor.deleteNode(node); - - if (node instanceof Element) { - this.lazyTranslator.stopObserving(node); - } - } } From 95f91a472cd3e8e622b33fba8d657853d36f8436 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 16 Apr 2025 22:16:43 +0200 Subject: [PATCH 037/313] refactor: remove class --- src/DomNodesTranslator.ts | 68 ++++++++++++++++++++----- src/NodeStorage.ts | 76 ---------------------------- src/__tests__/NodeStorage.test.ts | 84 ------------------------------- 3 files changed, 55 insertions(+), 173 deletions(-) delete mode 100644 src/NodeStorage.ts delete mode 100644 src/__tests__/NodeStorage.test.ts diff --git a/src/DomNodesTranslator.ts b/src/DomNodesTranslator.ts index f1b4f3e..dcca014 100644 --- a/src/DomNodesTranslator.ts +++ b/src/DomNodesTranslator.ts @@ -1,30 +1,59 @@ -import { NodeStorage } from './NodeStorage'; import { handleTree } from './utils/handleTree'; import { isInViewport } from './utils/isInViewport'; import { TranslatableNodePredicate, TranslatorInterface } from '.'; +export interface NodeData { + /** + * Unique node identifier + */ + id: number; + + /** + * Each node update should increase the value + */ + updateId: number; + + /** + * Contains `updateId` value at time when start node translation + */ + translateContext: number; + + /** + * Original node text, before start translation + * Contains `null` for node that not been translated yet + */ + originalText: null | string; + + /** + * Priority to translate node. The bigger the faster will translate + */ + priority: number; +} + /** - * Class DomNodesTranslator responsible for translating DOM nodes + * Manages translation of DOM nodes: + * Registers nodes and initiates translation. Triggers translation on update, addition, or deletion */ export class DomNodesTranslator { + private idCounter = 0; + private nodeStorage = new WeakMap(); + constructor( private isTranslatableNode: TranslatableNodePredicate, - private nodeStorage: NodeStorage, private readonly translateCallback: TranslatorInterface, ) {} - public isNodeStorageHas(node: Node) { + public has(node: Node) { return this.nodeStorage.has(node); } public getOriginalNodeText(node: Node) { const nodeData = this.nodeStorage.get(node); - return nodeData ? { originalText: nodeData.originalText } : null; } public addNode = (node: Node) => { - if (this.isNodeStorageHas(node)) return; + if (this.has(node)) return; // Skip empty text if (node.nodeValue === null || node.nodeValue.trim().length == 0) return; @@ -32,28 +61,41 @@ export class DomNodesTranslator { // Skip not translatable nodes if (!this.isTranslatableNode(node)) return; - this.nodeStorage.add(node, this.getNodePriority(node)); + this.nodeStorage.set(node, { + id: this.idCounter++, + updateId: 1, + translateContext: 0, + originalText: null, + priority: this.getNodePriority(node), + }); this.translateNode(node); }; public deleteNode(node: Node, onlyTarget = false) { + // Delete all attributes and inner nodes if (node instanceof Element && !onlyTarget) { - // Delete all attributes and inner nodes handleTree(node, (node) => { this.deleteNode(node, true); }); } - this.nodeStorage.delete(node); + const nodeData = this.nodeStorage.get(node); + if (nodeData !== undefined) { + // Restore original text if text been replaced + if (nodeData.originalText !== null) { + node.nodeValue = nodeData.originalText; + } + this.nodeStorage.delete(node); + } } // Updates never be lazy public updateNode(node: Node) { - // update only if the node is in storage - if (!this.nodeStorage.get(node)) return; - - this.nodeStorage.update(node); + const nodeData = this.nodeStorage.get(node); + if (nodeData !== undefined) { + nodeData.updateId++; + } this.translateNode(node); } diff --git a/src/NodeStorage.ts b/src/NodeStorage.ts deleted file mode 100644 index f1ff08e..0000000 --- a/src/NodeStorage.ts +++ /dev/null @@ -1,76 +0,0 @@ -export interface NodeData { - /** - * Unique node identifier - */ - id: number; - - /** - * Each node update should increase the value - */ - updateId: number; - - /** - * Contains `updateId` value at time when start node translation - */ - translateContext: number; - - /** - * Original node text, before start translation - * Contains `null` for node that not been translated yet - */ - originalText: null | string; - - /** - * Priority to translate node. The bigger the faster will translate - */ - priority: number; -} - -/** - * The NodeStorage class store nodes data - */ - -export class NodeStorage { - private idCounter = 0; - private nodeStorage = new WeakMap(); - - public has(node: Node) { - return this.nodeStorage.has(node); - } - - public get(node: Node) { - return this.nodeStorage.get(node) ?? null; - } - - public add(node: Node, priority: number) { - if (this.nodeStorage.has(node)) { - return; - } - - this.nodeStorage.set(node, { - id: this.idCounter++, - updateId: 1, - translateContext: 0, - originalText: null, - priority, - }); - } - - public update(node: Node) { - const nodeData = this.nodeStorage.get(node); - if (nodeData !== undefined) { - nodeData.updateId++; - } - } - - public delete(node: Node) { - const nodeData = this.nodeStorage.get(node); - if (nodeData !== undefined) { - // Restore original text if text been replaced - if (nodeData.originalText !== null) { - node.nodeValue = nodeData.originalText; - } - this.nodeStorage.delete(node); - } - } -} diff --git a/src/__tests__/NodeStorage.test.ts b/src/__tests__/NodeStorage.test.ts deleted file mode 100644 index 721e233..0000000 --- a/src/__tests__/NodeStorage.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { NodeStorage } from '../NodeStorage'; - -describe('NodeStorage', () => { - let div: Node; - let div1: Node; - - beforeEach(() => { - div = document.createElement('div'); - div.textContent = 'Hello world!'; - div1 = document.createElement('div'); - }); - - test('return correct value for a node that is not added', () => { - const nodeStorage = new NodeStorage(); - - expect(nodeStorage.has(div)).toBe(false); - expect(nodeStorage.get(div)).toBeNull(); - }); - - test('add a node to storage', () => { - const nodeStorage = new NodeStorage(); - - nodeStorage.add(div, 1); - - expect(nodeStorage.has(div)).toBe(true); - expect(nodeStorage.get(div)).toEqual( - expect.objectContaining({ - id: 0, - originalText: null, - priority: 1, - translateContext: 0, - updateId: 1, - }), - ); - }); - - test('can not add the same node twice', () => { - const nodeStorage = new NodeStorage(); - - nodeStorage.add(div, 1); - nodeStorage.add(div, 1); - - expect(nodeStorage.get(div)).toEqual( - expect.objectContaining({ - id: 0, - }), - ); - }); - - test('increase id counter after add new node', () => { - const nodeStorage = new NodeStorage(); - - nodeStorage.add(div, 1); - nodeStorage.add(div1, 1); - - expect(nodeStorage.get(div1)).toEqual( - expect.objectContaining({ - id: 1, - }), - ); - }); - - test('increase updateId after update a node', () => { - const nodeStorage = new NodeStorage(); - - nodeStorage.add(div, 1); - nodeStorage.update(div); - - expect(nodeStorage.get(div)).toEqual( - expect.objectContaining({ - updateId: 2, - }), - ); - }); - - test('remove node from storage', () => { - const nodeStorage = new NodeStorage(); - - nodeStorage.add(div, 1); - nodeStorage.delete(div); - - expect(nodeStorage.get(div)).toBeNull(); - }); -}); From 5626396021d0be848a3865b310d258d9e760877c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 16 Apr 2025 22:17:20 +0200 Subject: [PATCH 038/313] chore: add default value --- src/LazyTranslator.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/LazyTranslator.ts b/src/LazyTranslator.ts index 2fdbc03..26a3062 100644 --- a/src/LazyTranslator.ts +++ b/src/LazyTranslator.ts @@ -1,15 +1,15 @@ import { TranslatableNodePredicate } from '.'; type IntersectionConfig = { - root?: null | Element; - rootMargin?: string; - threshold?: number; + root: null | Element; + rootMargin: string; + threshold: number; }; type LazyTranslatorConfig = { isTranslatableNode: TranslatableNodePredicate; translator: (node: Node) => void; - intersectionConfig?: IntersectionConfig; + intersectionConfig?: Partial; }; /** @@ -23,7 +23,14 @@ export class LazyTranslator { private readonly config: LazyTranslatorConfig; constructor(config: LazyTranslatorConfig) { - this.config = config; + this.config = { + ...config, + intersectionConfig: { + root: null, + rootMargin: '0px', + threshold: 0, + }, + }; this.intersectionObserver = new IntersectionObserver((entries, observer) => { entries.forEach((entry) => { From 8a0d63d8bbb1e01b42f107a9c32fa3eb1ca6895a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 16 Apr 2025 22:27:35 +0200 Subject: [PATCH 039/313] chore: rename --- src/{DomNodesTranslator.ts => DomNodeTranslator.ts} | 4 ++-- src/NodesTranslator.ts | 11 +++++------ ...DomTranslationManager.ts => TranslationManager.ts} | 8 ++++---- 3 files changed, 11 insertions(+), 12 deletions(-) rename src/{DomNodesTranslator.ts => DomNodeTranslator.ts} (98%) rename src/{DomTranslationManager.ts => TranslationManager.ts} (92%) diff --git a/src/DomNodesTranslator.ts b/src/DomNodeTranslator.ts similarity index 98% rename from src/DomNodesTranslator.ts rename to src/DomNodeTranslator.ts index dcca014..0adc871 100644 --- a/src/DomNodesTranslator.ts +++ b/src/DomNodeTranslator.ts @@ -2,7 +2,7 @@ import { handleTree } from './utils/handleTree'; import { isInViewport } from './utils/isInViewport'; import { TranslatableNodePredicate, TranslatorInterface } from '.'; -export interface NodeData { +interface NodeData { /** * Unique node identifier */ @@ -34,7 +34,7 @@ export interface NodeData { * Manages translation of DOM nodes: * Registers nodes and initiates translation. Triggers translation on update, addition, or deletion */ -export class DomNodesTranslator { +export class DomNodeTranslator { private idCounter = 0; private nodeStorage = new WeakMap(); diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 263d6de..4f0432d 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,7 +1,7 @@ -import { DomNodesTranslator } from './DomNodesTranslator'; -import { Translation } from './DomTranslationManager'; +import { DomNodeTranslator } from './DomNodeTranslator'; import { LazyTranslator } from './LazyTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; +import { TranslationManager } from './TranslationManager'; import { configureTranslatableNodePredicate } from './utils/nodes'; export type TranslatableNodePredicate = (node: Node) => boolean; @@ -27,7 +27,7 @@ export type TranslatorInterface = (text: string, priority: number) => Promise(); @@ -40,9 +40,8 @@ export class NodesTranslator { config?.lazyTranslate !== undefined ? config?.lazyTranslate : true, }; - const domTranslationProcessor = new DomNodesTranslator( + const domTranslationProcessor = new DomNodeTranslator( this.config.isTranslatableNode, - // new NodeStorage(), translateCallback, ); @@ -51,7 +50,7 @@ export class NodesTranslator { translator: domTranslationProcessor.addNode, }); - this.translator = new Translation({ + this.translator = new TranslationManager({ config: this.config, domTranslationProcessor, lazyTranslator, diff --git a/src/DomTranslationManager.ts b/src/TranslationManager.ts similarity index 92% rename from src/DomTranslationManager.ts rename to src/TranslationManager.ts index bbd3b58..6e983d0 100644 --- a/src/DomTranslationManager.ts +++ b/src/TranslationManager.ts @@ -1,4 +1,4 @@ -import { DomNodesTranslator } from './DomNodesTranslator'; +import { DomNodeTranslator } from './DomNodeTranslator'; import { LazyTranslator } from './LazyTranslator'; import { InnerConfig } from './NodesTranslator'; import { handleTree } from './utils/handleTree'; @@ -6,16 +6,16 @@ import { isIntersectingNode } from './utils/isIntersectingNode'; type TranslationManagerConfig = { config: InnerConfig; - domTranslationProcessor: DomNodesTranslator; + domTranslationProcessor: DomNodeTranslator; lazyTranslator: LazyTranslator; }; /** * Class choose translation strategy: lazy or immediate. */ -export class Translation { +export class TranslationManager { private readonly config: InnerConfig; - private readonly domTranslationProcessor: DomNodesTranslator; + private readonly domTranslationProcessor: DomNodeTranslator; private readonly lazyTranslator: LazyTranslator; constructor({ From 59d563f28d17d02525e4203dff5987e758b6c91e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 16 Apr 2025 23:22:00 +0200 Subject: [PATCH 040/313] test: update test --- src/__tests__/DomNodesTranslator.test.ts | 127 +++++++++--------- .../DomNodesTranslator.test.ts.snap | 78 ----------- 2 files changed, 60 insertions(+), 145 deletions(-) delete mode 100644 src/__tests__/__snapshots__/DomNodesTranslator.test.ts.snap diff --git a/src/__tests__/DomNodesTranslator.test.ts b/src/__tests__/DomNodesTranslator.test.ts index 7d66333..fe0d253 100644 --- a/src/__tests__/DomNodesTranslator.test.ts +++ b/src/__tests__/DomNodesTranslator.test.ts @@ -1,20 +1,9 @@ -import { readFileSync } from 'fs'; - -import { DomNodesTranslator } from '../DomNodesTranslator'; -import { NodeStorage } from '../NodeStorage'; +import { DomNodeTranslator } from '../DomNodeTranslator'; import { handleTree } from '../utils/handleTree'; -import { - awaitTranslation, - containsRegex, - fillDocument, - TRANSLATION_SYMBOL, - translator, -} from './utils'; - -const sample = readFileSync(__dirname + '/sample.html', 'utf8'); - -describe('base usage', () => { - let domNodesTranslator: DomNodesTranslator; +import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; + +describe('DomNodeTranslator base usage', () => { + let domNodesTranslator: DomNodeTranslator; let div: Element; const spy = vi.fn(async (node: Node) => { @@ -33,31 +22,22 @@ describe('base usage', () => { const isTranslatableNode = (node: Node) => node instanceof Text; - domNodesTranslator = new DomNodesTranslator( - isTranslatableNode, - new NodeStorage(), - translator, - ); + domNodesTranslator = new DomNodeTranslator(isTranslatableNode, translator); }); - test('translate whole document', async () => { - fillDocument(sample); - const parsedHTML = document.documentElement.outerHTML; - - // translate document - if (document.documentElement instanceof Element) { - handleTree(document.documentElement, (node) => { - domNodesTranslator.addNode(node); - }); - } + test('correct translate element', async () => { + const div = document.createElement('div'); + const originalText = 'Hello world!'; + div.innerHTML = originalText; + domNodesTranslator.addNode(div.childNodes[0]); await awaitTranslation(); - expect(document.documentElement.outerHTML).toMatchSnapshot(); - // disable translation + expect(div.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); - domNodesTranslator.deleteNode(document.documentElement); - expect(document.documentElement.outerHTML).toBe(parsedHTML); + // disable translation + domNodesTranslator.deleteNode(div.childNodes[0]); + expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); test('getNodeData returns the original text', async () => { @@ -87,50 +67,63 @@ describe('base usage', () => { ); }); - test('disable translation only for the target node', async () => { - const handelTree1 = (node: Node) => { + test('isNodeStorageHas returns correct result', async () => { + domNodesTranslator.addNode(div.childNodes[0]); + await awaitTranslation(); + + expect(domNodesTranslator.has(div.childNodes[0])).toBe(true); + + //delete element + domNodesTranslator.deleteNode(div.childNodes[0]); + expect(domNodesTranslator.has(div.childNodes[0])).toBe(false); + }); + + describe('DeleteNode', () => { + let parentDiv: Element; + let childDiv: Element; + + const handelTree = (node: Node, callback: (node: Node) => void) => { if (node instanceof Element) { handleTree(node, (node) => { - domNodesTranslator.addNode(node); + callback(node); }); } }; - const div1 = document.createElement('div'); - div1.innerHTML = 'Hello world too!'; - div.append(div1); - // delete the target element and its nested items - handelTree1(div1); - await awaitTranslation(); + beforeEach(() => { + parentDiv = document.createElement('div'); + parentDiv.innerHTML = 'Hello world!'; - domNodesTranslator.deleteNode(div); + childDiv = document.createElement('div'); + childDiv.innerHTML = 'Hello world too!'; + parentDiv.append(childDiv); + }); - // child node and target has not translated text - expect(div1.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + test('delete translations from all nodes in the tree', async () => { + // delete the target element and its nested items + handelTree(parentDiv, domNodesTranslator.addNode); + await awaitTranslation(); - // delete translation only for the target element - handelTree1(div); - await awaitTranslation(); + domNodesTranslator.deleteNode(parentDiv); - domNodesTranslator.deleteNode(div.childNodes[0], true); + // child node and target has not translated text + expect(parentDiv.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(childDiv.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + }); - expect(div.childNodes[0].textContent).not.toMatch( - containsRegex(TRANSLATION_SYMBOL), - ); - // child element still has translation - expect(div1.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); - }); + test('delete translation from the selected node', async () => { + // delete translation only for the target element + handelTree(parentDiv, domNodesTranslator.addNode); + await awaitTranslation(); - test('isNodeStorageHas returns correct result', async () => { - domNodesTranslator.addNode(div.childNodes[0]); - await awaitTranslation(); - - expect(domNodesTranslator.isNodeStorageHas(div.childNodes[0])).toBe(true); + domNodesTranslator.deleteNode(parentDiv.childNodes[0], true); - //delete element - domNodesTranslator.deleteNode(div.childNodes[0]); - expect(domNodesTranslator.isNodeStorageHas(div.childNodes[0])).toBe(false); + expect(parentDiv.childNodes[0].textContent).not.toMatch( + containsRegex(TRANSLATION_SYMBOL), + ); + // child element still has translation + expect(childDiv.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); + }); }); test('updateNode should be call ones', async () => { @@ -138,7 +131,7 @@ describe('base usage', () => { // spy on the updateNode method const updateNodesSpy = vi.spyOn( - domNodesTranslator as DomNodesTranslator, + domNodesTranslator as DomNodeTranslator, 'updateNode', ); diff --git a/src/__tests__/__snapshots__/DomNodesTranslator.test.ts.snap b/src/__tests__/__snapshots__/DomNodesTranslator.test.ts.snap deleted file mode 100644 index 719714b..0000000 --- a/src/__tests__/__snapshots__/DomNodesTranslator.test.ts.snap +++ /dev/null @@ -1,78 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`base usage > translate whole document 1`] = ` -" - - ***TRANSLATED***Demo page with challenges for DOM translator - - - - - - -***TRANSLATED*** - Text with no container -
***TRANSLATED*** - Text in div
-

***TRANSLATED***Text inside container ***TRANSLATED***link text

- - image alt text -
***TRANSLATED***Text with preserved formatting
***TRANSLATED*** - - Some code: -
			***TRANSLATED***
-				const name = "Jeff";
-				console.log("Your name is " + name);
-			
-		
- -
-
-

***TRANSLATED***Words can be like X-rays, if you use them properly—they’ll go through anything. You read and you’re pierced.

-
-
***TRANSLATED***—Aldous Huxley, ***TRANSLATED***Brave New World
-
- -
- - - - - - - - - - - -
- -
-
***TRANSLATED***This text must not be translated ***TRANSLATED***this and ***TRANSLATED***this***TRANSLATED*** text too***TRANSLATED***.
-
***TRANSLATED***This text must not be translated, since it is editable
- -
***TRANSLATED***Text with preserved formatting
- -
-
***TRANSLATED***Items must not be translated:
-
***TRANSLATED***Foo
-
***TRANSLATED***Bar ***TRANSLATED***baz
-
- - - -
-
- - - - -" -`; From da5448ec439486841519bdbe1f55d68f4871f7d1 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 16 Apr 2025 23:22:33 +0200 Subject: [PATCH 041/313] chore: improve class description --- src/TranslationManager.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/TranslationManager.ts b/src/TranslationManager.ts index 6e983d0..102cefe 100644 --- a/src/TranslationManager.ts +++ b/src/TranslationManager.ts @@ -11,7 +11,7 @@ type TranslationManagerConfig = { }; /** - * Class choose translation strategy: lazy or immediate. + * Class coordinates the processing of DOM nodes for translation. Choose translation strategy: lazy or immediate. */ export class TranslationManager { private readonly config: InnerConfig; @@ -53,7 +53,7 @@ export class TranslationManager { return; } - // Ignore lazy translation for non-intersecting nodes and translate it immediately + // translate later or immediately if (this.config.lazyTranslate && this.tryLazyTranslate(node)) { return; } @@ -76,6 +76,7 @@ export class TranslationManager { const observableNode = node instanceof Attr ? node.ownerElement : node.parentElement; + // Ignore lazy translation for non-intersecting nodes and translate it immediately if ( isAttachedToDOM && observableNode !== null && From 0130dc4e345eb66ed66f3c2061c61c46f527d00e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 17 Apr 2025 01:13:25 +0200 Subject: [PATCH 042/313] fix: move expression to if block --- src/DomNodeTranslator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DomNodeTranslator.ts b/src/DomNodeTranslator.ts index 0adc871..aca7f52 100644 --- a/src/DomNodeTranslator.ts +++ b/src/DomNodeTranslator.ts @@ -95,8 +95,8 @@ export class DomNodeTranslator { const nodeData = this.nodeStorage.get(node); if (nodeData !== undefined) { nodeData.updateId++; + this.translateNode(node); } - this.translateNode(node); } /** From 557b5118c81872375bfe93dc6983c4d7a4fa022e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 17 Apr 2025 01:32:28 +0200 Subject: [PATCH 043/313] test: remove unnecessary code --- src/__tests__/DomNodesTranslator.test.ts | 35 +++++++----------------- src/__tests__/LazyTranslator.test.ts | 4 +-- src/__tests__/utils.ts | 7 ----- 3 files changed, 11 insertions(+), 35 deletions(-) diff --git a/src/__tests__/DomNodesTranslator.test.ts b/src/__tests__/DomNodesTranslator.test.ts index fe0d253..40f0b82 100644 --- a/src/__tests__/DomNodesTranslator.test.ts +++ b/src/__tests__/DomNodesTranslator.test.ts @@ -6,16 +6,6 @@ describe('DomNodeTranslator base usage', () => { let domNodesTranslator: DomNodeTranslator; let div: Element; - const spy = vi.fn(async (node: Node) => { - if (node.textContent) { - node.textContent = await translator(node.textContent); - } - }); - - afterEach(() => { - spy.mockClear(); - }); - beforeEach(() => { div = document.createElement('div'); div.innerHTML = 'Hello world!'; @@ -45,7 +35,6 @@ describe('DomNodeTranslator base usage', () => { // translate domNodesTranslator.addNode(div.childNodes[0]); - await awaitTranslation(); expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toEqual( @@ -57,8 +46,8 @@ describe('DomNodeTranslator base usage', () => { test('not translate empty element', async () => { div.innerHTML = ' '; - // translate document + // translate domNodesTranslator.addNode(div.childNodes[0]); await awaitTranslation(); @@ -82,7 +71,7 @@ describe('DomNodeTranslator base usage', () => { let parentDiv: Element; let childDiv: Element; - const handelTree = (node: Node, callback: (node: Node) => void) => { + const handleElementTree = (node: Node, callback: (node: Node) => void) => { if (node instanceof Element) { handleTree(node, (node) => { callback(node); @@ -100,8 +89,7 @@ describe('DomNodeTranslator base usage', () => { }); test('delete translations from all nodes in the tree', async () => { - // delete the target element and its nested items - handelTree(parentDiv, domNodesTranslator.addNode); + handleElementTree(parentDiv, domNodesTranslator.addNode); await awaitTranslation(); domNodesTranslator.deleteNode(parentDiv); @@ -112,12 +100,12 @@ describe('DomNodeTranslator base usage', () => { }); test('delete translation from the selected node', async () => { - // delete translation only for the target element - handelTree(parentDiv, domNodesTranslator.addNode); + handleElementTree(parentDiv, domNodesTranslator.addNode); await awaitTranslation(); domNodesTranslator.deleteNode(parentDiv.childNodes[0], true); + //target element has not translation expect(parentDiv.childNodes[0].textContent).not.toMatch( containsRegex(TRANSLATION_SYMBOL), ); @@ -127,7 +115,9 @@ describe('DomNodeTranslator base usage', () => { }); test('updateNode should be call ones', async () => { - document.body.appendChild(div); + const div = document.createElement('div'); + const originalText = 'Hello world!'; + div.innerHTML = originalText; // spy on the updateNode method const updateNodesSpy = vi.spyOn( @@ -136,17 +126,13 @@ describe('DomNodeTranslator base usage', () => { ); // translate element - if (div instanceof Element) { - handleTree(div, (node) => { - domNodesTranslator.addNode(node); - }); - } + domNodesTranslator.addNode(div.childNodes[0]); await awaitTranslation(); // update element - const newText = 'Goodbye world!'; div.innerHTML = newText; + domNodesTranslator.addNode(div.childNodes[0]); await awaitTranslation(); @@ -154,7 +140,6 @@ describe('DomNodeTranslator base usage', () => { await awaitTranslation(); expect(div.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(updateNodesSpy).toBeCalledTimes(1); expect(updateNodesSpy.mock.calls[0][0]).toMatchObject( containsRegex(TRANSLATION_SYMBOL), diff --git a/src/__tests__/LazyTranslator.test.ts b/src/__tests__/LazyTranslator.test.ts index 8a1c597..aa4988c 100644 --- a/src/__tests__/LazyTranslator.test.ts +++ b/src/__tests__/LazyTranslator.test.ts @@ -1,5 +1,3 @@ -import { vi } from 'vitest'; - import { LazyTranslator } from '../LazyTranslator'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL } from './utils'; @@ -11,7 +9,7 @@ const translator = vi.fn().mockImplementation(async (node: Text) => { const isTranslatableNode = (node: Node) => node instanceof Text; -describe('base usage', () => { +describe('LazyTranslator base usage', () => { const divElement = document.createElement('div'); const textNode = document.createTextNode('Hello, World!'); diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts index 1ef6fbf..6d5d98a 100644 --- a/src/__tests__/utils.ts +++ b/src/__tests__/utils.ts @@ -1,9 +1,6 @@ export const delay = (time: number) => new Promise((res) => setTimeout(res, time)); export const awaitTranslation = () => delay(120); -export const composeName = (...args: (string | boolean)[]) => - args.filter(Boolean).join(' '); - export const TRANSLATION_SYMBOL = '***TRANSLATED***'; export const translator = async (text: string) => TRANSLATION_SYMBOL + text; @@ -11,7 +8,3 @@ export const escapeRegexString = (input: string) => input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); export const containsRegex = (input: string) => new RegExp(`${escapeRegexString(input)}`); - -export const fillDocument = (text: string) => { - document.write(text); -}; From 7f079032321ca927d81d0f66be4956538211e284 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 17 Apr 2025 01:40:17 +0200 Subject: [PATCH 044/313] chore: rename --- src/NodesTranslator.ts | 6 +-- src/TranslationManager.ts | 22 +++++------ ...ator.test.ts => DomNodeTranslator.test.ts} | 38 +++++++++---------- 3 files changed, 31 insertions(+), 35 deletions(-) rename src/__tests__/{DomNodesTranslator.test.ts => DomNodeTranslator.test.ts} (75%) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 4f0432d..3fc2dab 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -40,19 +40,19 @@ export class NodesTranslator { config?.lazyTranslate !== undefined ? config?.lazyTranslate : true, }; - const domTranslationProcessor = new DomNodeTranslator( + const domNodeTranslator = new DomNodeTranslator( this.config.isTranslatableNode, translateCallback, ); const lazyTranslator = new LazyTranslator({ isTranslatableNode: this.config.isTranslatableNode, - translator: domTranslationProcessor.addNode, + translator: domNodeTranslator.addNode, }); this.translator = new TranslationManager({ config: this.config, - domTranslationProcessor, + domNodeTranslator, lazyTranslator, }); } diff --git a/src/TranslationManager.ts b/src/TranslationManager.ts index 102cefe..545c5f3 100644 --- a/src/TranslationManager.ts +++ b/src/TranslationManager.ts @@ -6,7 +6,7 @@ import { isIntersectingNode } from './utils/isIntersectingNode'; type TranslationManagerConfig = { config: InnerConfig; - domTranslationProcessor: DomNodeTranslator; + domNodeTranslator: DomNodeTranslator; lazyTranslator: LazyTranslator; }; @@ -15,29 +15,25 @@ type TranslationManagerConfig = { */ export class TranslationManager { private readonly config: InnerConfig; - private readonly domTranslationProcessor: DomNodeTranslator; + private readonly domNodeTranslator: DomNodeTranslator; private readonly lazyTranslator: LazyTranslator; - constructor({ - config, - domTranslationProcessor, - lazyTranslator, - }: TranslationManagerConfig) { + constructor({ config, domNodeTranslator, lazyTranslator }: TranslationManagerConfig) { this.config = config; - this.domTranslationProcessor = domTranslationProcessor; + this.domNodeTranslator = domNodeTranslator; this.lazyTranslator = lazyTranslator; } public getNodeData(node: Node) { - return this.domTranslationProcessor.getOriginalNodeText(node); + return this.domNodeTranslator.getOriginalNodeText(node); } public updateNode(node: Node) { - this.domTranslationProcessor.updateNode(node); + this.domNodeTranslator.updateNode(node); } public isNodeStorageHas(node: Node) { - return this.domTranslationProcessor.has(node); + return this.domNodeTranslator.has(node); } public addNode(node: Node) { @@ -58,11 +54,11 @@ export class TranslationManager { return; } - this.domTranslationProcessor.addNode(node); + this.domNodeTranslator.addNode(node); } public deleteNode(node: Node) { - this.domTranslationProcessor.deleteNode(node); + this.domNodeTranslator.deleteNode(node); if (node instanceof Element) { this.lazyTranslator.stopObserving(node); diff --git a/src/__tests__/DomNodesTranslator.test.ts b/src/__tests__/DomNodeTranslator.test.ts similarity index 75% rename from src/__tests__/DomNodesTranslator.test.ts rename to src/__tests__/DomNodeTranslator.test.ts index 40f0b82..b615ad9 100644 --- a/src/__tests__/DomNodesTranslator.test.ts +++ b/src/__tests__/DomNodeTranslator.test.ts @@ -3,7 +3,7 @@ import { handleTree } from '../utils/handleTree'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; describe('DomNodeTranslator base usage', () => { - let domNodesTranslator: DomNodeTranslator; + let domNodeTranslator: DomNodeTranslator; let div: Element; beforeEach(() => { @@ -12,7 +12,7 @@ describe('DomNodeTranslator base usage', () => { const isTranslatableNode = (node: Node) => node instanceof Text; - domNodesTranslator = new DomNodeTranslator(isTranslatableNode, translator); + domNodeTranslator = new DomNodeTranslator(isTranslatableNode, translator); }); test('correct translate element', async () => { @@ -20,13 +20,13 @@ describe('DomNodeTranslator base usage', () => { const originalText = 'Hello world!'; div.innerHTML = originalText; - domNodesTranslator.addNode(div.childNodes[0]); + domNodeTranslator.addNode(div.childNodes[0]); await awaitTranslation(); expect(div.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); // disable translation - domNodesTranslator.deleteNode(div.childNodes[0]); + domNodeTranslator.deleteNode(div.childNodes[0]); expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); @@ -34,10 +34,10 @@ describe('DomNodeTranslator base usage', () => { const originalText = 'Hello world!'; // translate - domNodesTranslator.addNode(div.childNodes[0]); + domNodeTranslator.addNode(div.childNodes[0]); await awaitTranslation(); - expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toEqual( + expect(domNodeTranslator.getOriginalNodeText(div.childNodes[0])).toEqual( expect.objectContaining({ originalText: originalText, }), @@ -48,7 +48,7 @@ describe('DomNodeTranslator base usage', () => { div.innerHTML = ' '; // translate - domNodesTranslator.addNode(div.childNodes[0]); + domNodeTranslator.addNode(div.childNodes[0]); await awaitTranslation(); expect(div.childNodes[0].textContent).not.toMatch( @@ -57,14 +57,14 @@ describe('DomNodeTranslator base usage', () => { }); test('isNodeStorageHas returns correct result', async () => { - domNodesTranslator.addNode(div.childNodes[0]); + domNodeTranslator.addNode(div.childNodes[0]); await awaitTranslation(); - expect(domNodesTranslator.has(div.childNodes[0])).toBe(true); + expect(domNodeTranslator.has(div.childNodes[0])).toBe(true); //delete element - domNodesTranslator.deleteNode(div.childNodes[0]); - expect(domNodesTranslator.has(div.childNodes[0])).toBe(false); + domNodeTranslator.deleteNode(div.childNodes[0]); + expect(domNodeTranslator.has(div.childNodes[0])).toBe(false); }); describe('DeleteNode', () => { @@ -89,10 +89,10 @@ describe('DomNodeTranslator base usage', () => { }); test('delete translations from all nodes in the tree', async () => { - handleElementTree(parentDiv, domNodesTranslator.addNode); + handleElementTree(parentDiv, domNodeTranslator.addNode); await awaitTranslation(); - domNodesTranslator.deleteNode(parentDiv); + domNodeTranslator.deleteNode(parentDiv); // child node and target has not translated text expect(parentDiv.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); @@ -100,10 +100,10 @@ describe('DomNodeTranslator base usage', () => { }); test('delete translation from the selected node', async () => { - handleElementTree(parentDiv, domNodesTranslator.addNode); + handleElementTree(parentDiv, domNodeTranslator.addNode); await awaitTranslation(); - domNodesTranslator.deleteNode(parentDiv.childNodes[0], true); + domNodeTranslator.deleteNode(parentDiv.childNodes[0], true); //target element has not translation expect(parentDiv.childNodes[0].textContent).not.toMatch( @@ -121,22 +121,22 @@ describe('DomNodeTranslator base usage', () => { // spy on the updateNode method const updateNodesSpy = vi.spyOn( - domNodesTranslator as DomNodeTranslator, + domNodeTranslator as DomNodeTranslator, 'updateNode', ); // translate element - domNodesTranslator.addNode(div.childNodes[0]); + domNodeTranslator.addNode(div.childNodes[0]); await awaitTranslation(); // update element const newText = 'Goodbye world!'; div.innerHTML = newText; - domNodesTranslator.addNode(div.childNodes[0]); + domNodeTranslator.addNode(div.childNodes[0]); await awaitTranslation(); - domNodesTranslator.updateNode(div.childNodes[0]); + domNodeTranslator.updateNode(div.childNodes[0]); await awaitTranslation(); expect(div.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); From c565cb8fee7f505740b40f19ea7057affeb835d2 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 23 Apr 2025 18:57:06 +0200 Subject: [PATCH 045/313] refactor: add method --- src/NodesTranslator.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 3fc2dab..473b691 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -29,8 +29,6 @@ export class NodesTranslator { private readonly config: InnerConfig; private translator: TranslationManager; - private readonly observedNodesStorage = new Map(); - constructor(translateCallback: TranslatorInterface, config?: Config) { this.config = { ...config, @@ -57,6 +55,7 @@ export class NodesTranslator { }); } + private readonly observedNodesStorage = new Map(); public observe(node: Element) { if (this.observedNodesStorage.has(node)) { throw new Error('Node already under observe'); @@ -104,4 +103,8 @@ export class NodesTranslator { this.observedNodesStorage.get(node)?.disconnect(); this.observedNodesStorage.delete(node); } + + public getNodeData(node: Node) { + return this.translator.getNodeData(node); + } } From 8c05223b61364024141a2f357cf7dd34d172e514 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 25 Apr 2025 01:54:42 +0200 Subject: [PATCH 046/313] chore: trigger ci From 1e44adc08ef18afa2cbb1a61e8528fa70cebd772 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 11:16:20 +0200 Subject: [PATCH 047/313] chore: rename --- ...LazyTranslator.ts => LazyDOMTranslator.ts} | 25 ++++++++++--------- src/NodesTranslator.ts | 4 +-- src/TranslationManager.ts | 10 ++++---- src/__tests__/LazyTranslator.test.ts | 18 ++++++------- 4 files changed, 29 insertions(+), 28 deletions(-) rename src/{LazyTranslator.ts => LazyDOMTranslator.ts} (74%) diff --git a/src/LazyTranslator.ts b/src/LazyDOMTranslator.ts similarity index 74% rename from src/LazyTranslator.ts rename to src/LazyDOMTranslator.ts index 26a3062..88ccf8f 100644 --- a/src/LazyTranslator.ts +++ b/src/LazyDOMTranslator.ts @@ -16,8 +16,8 @@ type LazyTranslatorConfig = { * Translates nodes only if they intersect the viewport */ -export class LazyTranslator { - private readonly intersectStorage = new WeakSet(); +export class LazyDOMTranslator { + private readonly nodesObservedForIntersection = new WeakSet(); private intersectionObserver: IntersectionObserver; private readonly config: LazyTranslatorConfig; @@ -36,9 +36,10 @@ export class LazyTranslator { entries.forEach((entry) => { const node = entry.target; - if (!this.intersectStorage.has(node) || !entry.isIntersecting) return; + if (!this.nodesObservedForIntersection.has(node) || !entry.isIntersecting) + return; - this.intersectStorage.delete(node); + this.nodesObservedForIntersection.delete(node); observer.unobserve(node); this.handlerIntersectNode(node); @@ -46,16 +47,16 @@ export class LazyTranslator { }, this.config.intersectionConfig); } - public stopObserving(node: Element) { - this.intersectStorage.delete(node); - - this.intersectionObserver.unobserve(node); + public attach(node: Element) { + if (this.nodesObservedForIntersection.has(node)) return; + this.nodesObservedForIntersection.add(node); + this.intersectionObserver.observe(node); } - public startObserving(node: Element) { - if (this.intersectStorage.has(node)) return; - this.intersectStorage.add(node); - this.intersectionObserver.observe(node); + public detach(node: Element) { + this.nodesObservedForIntersection.delete(node); + + this.intersectionObserver.unobserve(node); } private handlerIntersectNode(node: Node) { diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 473b691..2573796 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,5 +1,5 @@ import { DomNodeTranslator } from './DomNodeTranslator'; -import { LazyTranslator } from './LazyTranslator'; +import { LazyDOMTranslator } from './LazyDOMTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; import { TranslationManager } from './TranslationManager'; import { configureTranslatableNodePredicate } from './utils/nodes'; @@ -43,7 +43,7 @@ export class NodesTranslator { translateCallback, ); - const lazyTranslator = new LazyTranslator({ + const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode: this.config.isTranslatableNode, translator: domNodeTranslator.addNode, }); diff --git a/src/TranslationManager.ts b/src/TranslationManager.ts index 545c5f3..5ee28a3 100644 --- a/src/TranslationManager.ts +++ b/src/TranslationManager.ts @@ -1,5 +1,5 @@ import { DomNodeTranslator } from './DomNodeTranslator'; -import { LazyTranslator } from './LazyTranslator'; +import { LazyDOMTranslator } from './LazyDOMTranslator'; import { InnerConfig } from './NodesTranslator'; import { handleTree } from './utils/handleTree'; import { isIntersectingNode } from './utils/isIntersectingNode'; @@ -7,7 +7,7 @@ import { isIntersectingNode } from './utils/isIntersectingNode'; type TranslationManagerConfig = { config: InnerConfig; domNodeTranslator: DomNodeTranslator; - lazyTranslator: LazyTranslator; + lazyTranslator: LazyDOMTranslator; }; /** @@ -16,7 +16,7 @@ type TranslationManagerConfig = { export class TranslationManager { private readonly config: InnerConfig; private readonly domNodeTranslator: DomNodeTranslator; - private readonly lazyTranslator: LazyTranslator; + private readonly lazyTranslator: LazyDOMTranslator; constructor({ config, domNodeTranslator, lazyTranslator }: TranslationManagerConfig) { this.config = config; @@ -61,7 +61,7 @@ export class TranslationManager { this.domNodeTranslator.deleteNode(node); if (node instanceof Element) { - this.lazyTranslator.stopObserving(node); + this.lazyTranslator.detach(node); } } @@ -78,7 +78,7 @@ export class TranslationManager { observableNode !== null && isIntersectingNode(observableNode) ) { - this.lazyTranslator.startObserving(observableNode); + this.lazyTranslator.attach(observableNode); return true; } return false; diff --git a/src/__tests__/LazyTranslator.test.ts b/src/__tests__/LazyTranslator.test.ts index aa4988c..32b8019 100644 --- a/src/__tests__/LazyTranslator.test.ts +++ b/src/__tests__/LazyTranslator.test.ts @@ -1,4 +1,4 @@ -import { LazyTranslator } from '../LazyTranslator'; +import { LazyDOMTranslator } from '../LazyDOMTranslator'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL } from './utils'; require('intersection-observer'); @@ -21,9 +21,9 @@ describe('LazyTranslator base usage', () => { }); test('translate element at intersection', async () => { - const lazyTranslator = new LazyTranslator({ isTranslatableNode, translator }); + const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); - lazyTranslator.startObserving(divElement); + lazyTranslator.attach(divElement); await awaitTranslation(); // The mock function was called ones @@ -35,14 +35,14 @@ describe('LazyTranslator base usage', () => { }); test('translate node that intersect the custom ancestor', async () => { - const lazyTranslator = new LazyTranslator({ + const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator, intersectionConfig: { root: divElement, }, }); - lazyTranslator.startObserving(divElement); + lazyTranslator.attach(divElement); await awaitTranslation(); // The mock function was called ones @@ -54,11 +54,11 @@ describe('LazyTranslator base usage', () => { }); test('not translate nodes that not intersected', async () => { - const lazyTranslator = new LazyTranslator({ isTranslatableNode, translator }); + const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); const newDivElement = document.createElement('div'); - lazyTranslator.startObserving(newDivElement); + lazyTranslator.attach(newDivElement); await awaitTranslation(); // The mock function was not called @@ -68,7 +68,7 @@ describe('LazyTranslator base usage', () => { test('not translate node that not intersect the custom ancestor', async () => { const divElement = document.createElement('div'); - const lazyTranslator = new LazyTranslator({ + const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator, intersectionConfig: { @@ -78,7 +78,7 @@ describe('LazyTranslator base usage', () => { const newDivElement = document.createElement('div'); - lazyTranslator.startObserving(newDivElement); + lazyTranslator.attach(newDivElement); await awaitTranslation(); expect(translator.mock.calls).toHaveLength(0); From 661fcc458749557332b71174277155cff8aa3fd7 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 11:23:13 +0200 Subject: [PATCH 048/313] chore: add comment --- src/LazyDOMTranslator.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/LazyDOMTranslator.ts b/src/LazyDOMTranslator.ts index 88ccf8f..5a2ed7d 100644 --- a/src/LazyDOMTranslator.ts +++ b/src/LazyDOMTranslator.ts @@ -17,6 +17,7 @@ type LazyTranslatorConfig = { */ export class LazyDOMTranslator { + // Store the nodes that is under observing for intersection private readonly nodesObservedForIntersection = new WeakSet(); private intersectionObserver: IntersectionObserver; @@ -36,9 +37,12 @@ export class LazyDOMTranslator { entries.forEach((entry) => { const node = entry.target; + // Skip nodes that are not under observation or still is not intersected if (!this.nodesObservedForIntersection.has(node) || !entry.isIntersecting) return; + // Process the node once and forget it + // This makes it possible to observe the node again later if needed this.nodesObservedForIntersection.delete(node); observer.unobserve(node); From a1d53a20ef54d6527c78fc940c9fcd2dc35958cd Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 11:33:06 +0200 Subject: [PATCH 049/313] refactor: change constructor type --- src/LazyDOMTranslator.ts | 41 ++++++++-------------------- src/NodesTranslator.ts | 8 +++--- src/__tests__/LazyTranslator.test.ts | 12 +++----- 3 files changed, 20 insertions(+), 41 deletions(-) diff --git a/src/LazyDOMTranslator.ts b/src/LazyDOMTranslator.ts index 5a2ed7d..dd1449c 100644 --- a/src/LazyDOMTranslator.ts +++ b/src/LazyDOMTranslator.ts @@ -1,17 +1,5 @@ import { TranslatableNodePredicate } from '.'; -type IntersectionConfig = { - root: null | Element; - rootMargin: string; - threshold: number; -}; - -type LazyTranslatorConfig = { - isTranslatableNode: TranslatableNodePredicate; - translator: (node: Node) => void; - intersectionConfig?: Partial; -}; - /** * Translates nodes only if they intersect the viewport */ @@ -19,20 +7,15 @@ type LazyTranslatorConfig = { export class LazyDOMTranslator { // Store the nodes that is under observing for intersection private readonly nodesObservedForIntersection = new WeakSet(); - private intersectionObserver: IntersectionObserver; - - private readonly config: LazyTranslatorConfig; - - constructor(config: LazyTranslatorConfig) { - this.config = { - ...config, - intersectionConfig: { - root: null, - rootMargin: '0px', - threshold: 0, - }, - }; - + private readonly intersectionObserver: IntersectionObserver; + + constructor( + private readonly isTranslatableNode: TranslatableNodePredicate, + private readonly translator: (node: Node) => void, + config?: { + intersectionConfig?: IntersectionObserverInit; + }, + ) { this.intersectionObserver = new IntersectionObserver((entries, observer) => { entries.forEach((entry) => { const node = entry.target; @@ -48,7 +31,7 @@ export class LazyDOMTranslator { this.handlerIntersectNode(node); }); - }, this.config.intersectionConfig); + }, config?.intersectionConfig); } public attach(node: Element) { @@ -67,10 +50,10 @@ export class LazyDOMTranslator { // Translate child text nodes and attributes of target node // WARNING: we shall not touch inner nodes, because its may still not intersected node.childNodes.forEach((node) => { - if (node instanceof Element || !this.config.isTranslatableNode(node)) { + if (node instanceof Element || !this.isTranslatableNode(node)) { return; } - this.config.translator(node); + this.translator(node); }); } } diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 2573796..0e1542d 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -43,10 +43,10 @@ export class NodesTranslator { translateCallback, ); - const lazyTranslator = new LazyDOMTranslator({ - isTranslatableNode: this.config.isTranslatableNode, - translator: domNodeTranslator.addNode, - }); + const lazyTranslator = new LazyDOMTranslator( + this.config.isTranslatableNode, + domNodeTranslator.addNode, + ); this.translator = new TranslationManager({ config: this.config, diff --git a/src/__tests__/LazyTranslator.test.ts b/src/__tests__/LazyTranslator.test.ts index 32b8019..046505e 100644 --- a/src/__tests__/LazyTranslator.test.ts +++ b/src/__tests__/LazyTranslator.test.ts @@ -21,7 +21,7 @@ describe('LazyTranslator base usage', () => { }); test('translate element at intersection', async () => { - const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); + const lazyTranslator = new LazyDOMTranslator(isTranslatableNode, translator); lazyTranslator.attach(divElement); await awaitTranslation(); @@ -35,9 +35,7 @@ describe('LazyTranslator base usage', () => { }); test('translate node that intersect the custom ancestor', async () => { - const lazyTranslator = new LazyDOMTranslator({ - isTranslatableNode, - translator, + const lazyTranslator = new LazyDOMTranslator(isTranslatableNode, translator, { intersectionConfig: { root: divElement, }, @@ -54,7 +52,7 @@ describe('LazyTranslator base usage', () => { }); test('not translate nodes that not intersected', async () => { - const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); + const lazyTranslator = new LazyDOMTranslator(isTranslatableNode, translator); const newDivElement = document.createElement('div'); @@ -68,9 +66,7 @@ describe('LazyTranslator base usage', () => { test('not translate node that not intersect the custom ancestor', async () => { const divElement = document.createElement('div'); - const lazyTranslator = new LazyDOMTranslator({ - isTranslatableNode, - translator, + const lazyTranslator = new LazyDOMTranslator(isTranslatableNode, translator, { intersectionConfig: { root: divElement, }, From 64998c492b5e48a6b40113983408c66a155fdb9e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 11:48:24 +0200 Subject: [PATCH 050/313] chore: rename --- ...{DomNodeTranslator.ts => DOMTranslator.ts} | 18 ++++----- src/NodesTranslator.ts | 6 +-- src/TranslationManager.ts | 12 +++--- src/__tests__/DomNodeTranslator.test.ts | 39 +++++++++---------- 4 files changed, 36 insertions(+), 39 deletions(-) rename src/{DomNodeTranslator.ts => DOMTranslator.ts} (91%) diff --git a/src/DomNodeTranslator.ts b/src/DOMTranslator.ts similarity index 91% rename from src/DomNodeTranslator.ts rename to src/DOMTranslator.ts index aca7f52..26750bd 100644 --- a/src/DomNodeTranslator.ts +++ b/src/DOMTranslator.ts @@ -34,7 +34,7 @@ interface NodeData { * Manages translation of DOM nodes: * Registers nodes and initiates translation. Triggers translation on update, addition, or deletion */ -export class DomNodeTranslator { +export class DOMTranslator { private idCounter = 0; private nodeStorage = new WeakMap(); @@ -43,7 +43,7 @@ export class DomNodeTranslator { private readonly translateCallback: TranslatorInterface, ) {} - public has(node: Node) { + public hasNode(node: Node) { return this.nodeStorage.has(node); } @@ -52,8 +52,8 @@ export class DomNodeTranslator { return nodeData ? { originalText: nodeData.originalText } : null; } - public addNode = (node: Node) => { - if (this.has(node)) return; + public translateNode = (node: Node) => { + if (this.hasNode(node)) return; // Skip empty text if (node.nodeValue === null || node.nodeValue.trim().length == 0) return; @@ -69,14 +69,14 @@ export class DomNodeTranslator { priority: this.getNodePriority(node), }); - this.translateNode(node); + this.translateNodeContent(node); }; - public deleteNode(node: Node, onlyTarget = false) { + public restoreNode(node: Node, onlyTarget = false) { // Delete all attributes and inner nodes if (node instanceof Element && !onlyTarget) { handleTree(node, (node) => { - this.deleteNode(node, true); + this.restoreNode(node, true); }); } @@ -95,7 +95,7 @@ export class DomNodeTranslator { const nodeData = this.nodeStorage.get(node); if (nodeData !== undefined) { nodeData.updateId++; - this.translateNode(node); + this.translateNodeContent(node); } } @@ -127,7 +127,7 @@ export class DomNodeTranslator { /** * Call only for new and updated nodes */ - private translateNode(node: Node) { + private translateNodeContent(node: Node) { const nodeData = this.nodeStorage.get(node); if (!nodeData) { throw new Error('Node is not register'); diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 0e1542d..de6e8d0 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,4 +1,4 @@ -import { DomNodeTranslator } from './DomNodeTranslator'; +import { DOMTranslator } from './DOMTranslator'; import { LazyDOMTranslator } from './LazyDOMTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; import { TranslationManager } from './TranslationManager'; @@ -38,14 +38,14 @@ export class NodesTranslator { config?.lazyTranslate !== undefined ? config?.lazyTranslate : true, }; - const domNodeTranslator = new DomNodeTranslator( + const domNodeTranslator = new DOMTranslator( this.config.isTranslatableNode, translateCallback, ); const lazyTranslator = new LazyDOMTranslator( this.config.isTranslatableNode, - domNodeTranslator.addNode, + domNodeTranslator.translateNode, ); this.translator = new TranslationManager({ diff --git a/src/TranslationManager.ts b/src/TranslationManager.ts index 5ee28a3..7e832f6 100644 --- a/src/TranslationManager.ts +++ b/src/TranslationManager.ts @@ -1,4 +1,4 @@ -import { DomNodeTranslator } from './DomNodeTranslator'; +import { DOMTranslator } from './DOMTranslator'; import { LazyDOMTranslator } from './LazyDOMTranslator'; import { InnerConfig } from './NodesTranslator'; import { handleTree } from './utils/handleTree'; @@ -6,7 +6,7 @@ import { isIntersectingNode } from './utils/isIntersectingNode'; type TranslationManagerConfig = { config: InnerConfig; - domNodeTranslator: DomNodeTranslator; + domNodeTranslator: DOMTranslator; lazyTranslator: LazyDOMTranslator; }; @@ -15,7 +15,7 @@ type TranslationManagerConfig = { */ export class TranslationManager { private readonly config: InnerConfig; - private readonly domNodeTranslator: DomNodeTranslator; + private readonly domNodeTranslator: DOMTranslator; private readonly lazyTranslator: LazyDOMTranslator; constructor({ config, domNodeTranslator, lazyTranslator }: TranslationManagerConfig) { @@ -33,7 +33,7 @@ export class TranslationManager { } public isNodeStorageHas(node: Node) { - return this.domNodeTranslator.has(node); + return this.domNodeTranslator.hasNode(node); } public addNode(node: Node) { @@ -54,11 +54,11 @@ export class TranslationManager { return; } - this.domNodeTranslator.addNode(node); + this.domNodeTranslator.translateNode(node); } public deleteNode(node: Node) { - this.domNodeTranslator.deleteNode(node); + this.domNodeTranslator.restoreNode(node); if (node instanceof Element) { this.lazyTranslator.detach(node); diff --git a/src/__tests__/DomNodeTranslator.test.ts b/src/__tests__/DomNodeTranslator.test.ts index b615ad9..c32230f 100644 --- a/src/__tests__/DomNodeTranslator.test.ts +++ b/src/__tests__/DomNodeTranslator.test.ts @@ -1,9 +1,9 @@ -import { DomNodeTranslator } from '../DomNodeTranslator'; +import { DOMTranslator } from '../DOMTranslator'; import { handleTree } from '../utils/handleTree'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; describe('DomNodeTranslator base usage', () => { - let domNodeTranslator: DomNodeTranslator; + let domNodeTranslator: DOMTranslator; let div: Element; beforeEach(() => { @@ -12,7 +12,7 @@ describe('DomNodeTranslator base usage', () => { const isTranslatableNode = (node: Node) => node instanceof Text; - domNodeTranslator = new DomNodeTranslator(isTranslatableNode, translator); + domNodeTranslator = new DOMTranslator(isTranslatableNode, translator); }); test('correct translate element', async () => { @@ -20,13 +20,13 @@ describe('DomNodeTranslator base usage', () => { const originalText = 'Hello world!'; div.innerHTML = originalText; - domNodeTranslator.addNode(div.childNodes[0]); + domNodeTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); // disable translation - domNodeTranslator.deleteNode(div.childNodes[0]); + domNodeTranslator.restoreNode(div.childNodes[0]); expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); @@ -34,7 +34,7 @@ describe('DomNodeTranslator base usage', () => { const originalText = 'Hello world!'; // translate - domNodeTranslator.addNode(div.childNodes[0]); + domNodeTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(domNodeTranslator.getOriginalNodeText(div.childNodes[0])).toEqual( @@ -48,7 +48,7 @@ describe('DomNodeTranslator base usage', () => { div.innerHTML = ' '; // translate - domNodeTranslator.addNode(div.childNodes[0]); + domNodeTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.childNodes[0].textContent).not.toMatch( @@ -57,14 +57,14 @@ describe('DomNodeTranslator base usage', () => { }); test('isNodeStorageHas returns correct result', async () => { - domNodeTranslator.addNode(div.childNodes[0]); + domNodeTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); - expect(domNodeTranslator.has(div.childNodes[0])).toBe(true); + expect(domNodeTranslator.hasNode(div.childNodes[0])).toBe(true); //delete element - domNodeTranslator.deleteNode(div.childNodes[0]); - expect(domNodeTranslator.has(div.childNodes[0])).toBe(false); + domNodeTranslator.restoreNode(div.childNodes[0]); + expect(domNodeTranslator.hasNode(div.childNodes[0])).toBe(false); }); describe('DeleteNode', () => { @@ -89,10 +89,10 @@ describe('DomNodeTranslator base usage', () => { }); test('delete translations from all nodes in the tree', async () => { - handleElementTree(parentDiv, domNodeTranslator.addNode); + handleElementTree(parentDiv, domNodeTranslator.translateNode); await awaitTranslation(); - domNodeTranslator.deleteNode(parentDiv); + domNodeTranslator.restoreNode(parentDiv); // child node and target has not translated text expect(parentDiv.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); @@ -100,10 +100,10 @@ describe('DomNodeTranslator base usage', () => { }); test('delete translation from the selected node', async () => { - handleElementTree(parentDiv, domNodeTranslator.addNode); + handleElementTree(parentDiv, domNodeTranslator.translateNode); await awaitTranslation(); - domNodeTranslator.deleteNode(parentDiv.childNodes[0], true); + domNodeTranslator.restoreNode(parentDiv.childNodes[0], true); //target element has not translation expect(parentDiv.childNodes[0].textContent).not.toMatch( @@ -120,20 +120,17 @@ describe('DomNodeTranslator base usage', () => { div.innerHTML = originalText; // spy on the updateNode method - const updateNodesSpy = vi.spyOn( - domNodeTranslator as DomNodeTranslator, - 'updateNode', - ); + const updateNodesSpy = vi.spyOn(domNodeTranslator as DOMTranslator, 'updateNode'); // translate element - domNodeTranslator.addNode(div.childNodes[0]); + domNodeTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); // update element const newText = 'Goodbye world!'; div.innerHTML = newText; - domNodeTranslator.addNode(div.childNodes[0]); + domNodeTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); domNodeTranslator.updateNode(div.childNodes[0]); From 60a033b39e7c4e67a4fe4b0a8704d9787f300a7a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 11:50:00 +0200 Subject: [PATCH 051/313] refactor: improve api method --- src/DOMTranslator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DOMTranslator.ts b/src/DOMTranslator.ts index 26750bd..407157d 100644 --- a/src/DOMTranslator.ts +++ b/src/DOMTranslator.ts @@ -39,7 +39,7 @@ export class DOMTranslator { private nodeStorage = new WeakMap(); constructor( - private isTranslatableNode: TranslatableNodePredicate, + private readonly isTranslatableNode: TranslatableNodePredicate, private readonly translateCallback: TranslatorInterface, ) {} @@ -49,7 +49,7 @@ export class DOMTranslator { public getOriginalNodeText(node: Node) { const nodeData = this.nodeStorage.get(node); - return nodeData ? { originalText: nodeData.originalText } : null; + return nodeData ? nodeData.originalText : null; } public translateNode = (node: Node) => { From 2a84987c9095d3d4ba624391883650589dfd3420 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 12:07:04 +0200 Subject: [PATCH 052/313] chore: update comment and descriptions --- src/DOMTranslator.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/DOMTranslator.ts b/src/DOMTranslator.ts index 407157d..eb6eef5 100644 --- a/src/DOMTranslator.ts +++ b/src/DOMTranslator.ts @@ -31,8 +31,8 @@ interface NodeData { } /** - * Manages translation of DOM nodes: - * Registers nodes and initiates translation. Triggers translation on update, addition, or deletion + * Manages a translation state of DOM nodes, registers nodes and initiates translation. + * Updates the translation when a node is modified or deleted */ export class DOMTranslator { private idCounter = 0; @@ -72,6 +72,10 @@ export class DOMTranslator { this.translateNodeContent(node); }; + /** + * Restores the original node text + * @param onlyTarget determines whether only the target node or all its nested nodes will be restored + */ public restoreNode(node: Node, onlyTarget = false) { // Delete all attributes and inner nodes if (node instanceof Element && !onlyTarget) { @@ -90,7 +94,9 @@ export class DOMTranslator { } } - // Updates never be lazy + /** + * Translates node after it has been modified + */ public updateNode(node: Node) { const nodeData = this.nodeStorage.get(node); if (nodeData !== undefined) { @@ -151,7 +157,6 @@ export class DOMTranslator { return; } - // actualNodeData.translateData = text; actualNodeData.originalText = node.nodeValue !== null ? node.nodeValue : ''; actualNodeData.translateContext = actualNodeData.updateId + 1; node.nodeValue = text; From 08ab2a06ccf5c760076ad27bed6fad1a91ce9912 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 12:09:04 +0200 Subject: [PATCH 053/313] refactor: move method to function --- src/DOMTranslator.ts | 52 ++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/src/DOMTranslator.ts b/src/DOMTranslator.ts index eb6eef5..5348a6c 100644 --- a/src/DOMTranslator.ts +++ b/src/DOMTranslator.ts @@ -30,6 +30,31 @@ interface NodeData { priority: number; } +/** + * Calculate node priority for translate, the bigger number the importance text + */ +function getNodePriority(node: Node) { + let score = 0; + + if (node instanceof Attr) { + score += 1; + const parent = node.ownerElement; + if (parent && isInViewport(parent)) { + // Attribute of visible element is important than text of non-visible element + score += 2; + } + } else if (node instanceof Text) { + score += 2; + const parent = node.parentElement; + if (parent && isInViewport(parent)) { + // Text of visible element is most important node for translation + score += 2; + } + } + + return score; +} + /** * Manages a translation state of DOM nodes, registers nodes and initiates translation. * Updates the translation when a node is modified or deleted @@ -66,7 +91,7 @@ export class DOMTranslator { updateId: 1, translateContext: 0, originalText: null, - priority: this.getNodePriority(node), + priority: getNodePriority(node), }); this.translateNodeContent(node); @@ -105,31 +130,6 @@ export class DOMTranslator { } } - /** - * Calculate node priority for translate, the bigger number the importance text - */ - private getNodePriority = (node: Node) => { - let score = 0; - - if (node instanceof Attr) { - score += 1; - const parent = node.ownerElement; - if (parent && isInViewport(parent)) { - // Attribute of visible element is important than text of non-visible element - score += 2; - } - } else if (node instanceof Text) { - score += 2; - const parent = node.parentElement; - if (parent && isInViewport(parent)) { - // Text of visible element is most important node for translation - score += 2; - } - } - - return score; - }; - /** * Call only for new and updated nodes */ From 7d52cf0b18fd9992aaddf49bb104868eb72a6f4f Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 12:12:59 +0200 Subject: [PATCH 054/313] refactor: use early return --- src/DOMTranslator.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/DOMTranslator.ts b/src/DOMTranslator.ts index 5348a6c..1944865 100644 --- a/src/DOMTranslator.ts +++ b/src/DOMTranslator.ts @@ -110,13 +110,14 @@ export class DOMTranslator { } const nodeData = this.nodeStorage.get(node); - if (nodeData !== undefined) { - // Restore original text if text been replaced - if (nodeData.originalText !== null) { - node.nodeValue = nodeData.originalText; - } - this.nodeStorage.delete(node); + if (nodeData == undefined) { + return; + } + // Restore original text if text been replaced + if (nodeData.originalText !== null) { + node.nodeValue = nodeData.originalText; } + this.nodeStorage.delete(node); } /** @@ -124,10 +125,11 @@ export class DOMTranslator { */ public updateNode(node: Node) { const nodeData = this.nodeStorage.get(node); - if (nodeData !== undefined) { - nodeData.updateId++; - this.translateNodeContent(node); + if (nodeData == undefined) { + return; } + nodeData.updateId++; + this.translateNodeContent(node); } /** @@ -160,7 +162,6 @@ export class DOMTranslator { actualNodeData.originalText = node.nodeValue !== null ? node.nodeValue : ''; actualNodeData.translateContext = actualNodeData.updateId + 1; node.nodeValue = text; - return node; }); } } From 330326f1e44fb043e04eab5b603cfe12973da7a4 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 12:17:35 +0200 Subject: [PATCH 055/313] chore: rename entities --- src/NodesTranslator.ts | 20 +++++++++---------- ...ionManager.ts => TranslationDispatcher.ts} | 12 +++++------ 2 files changed, 16 insertions(+), 16 deletions(-) rename src/{TranslationManager.ts => TranslationDispatcher.ts} (91%) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index de6e8d0..7040c1f 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,7 +1,7 @@ import { DOMTranslator } from './DOMTranslator'; import { LazyDOMTranslator } from './LazyDOMTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; -import { TranslationManager } from './TranslationManager'; +import { TranslationDispatcher } from './TranslationDispatcher'; import { configureTranslatableNodePredicate } from './utils/nodes'; export type TranslatableNodePredicate = (node: Node) => boolean; @@ -27,7 +27,7 @@ export type TranslatorInterface = (text: string, priority: number) => Promise - this.translator.addNode(target), + this.translator.translateNode(target), ); observer.addHandler('elementRemoved', ({ target }) => - this.translator.deleteNode(target), + this.translator.restoreNode(target), ); observer.addHandler('characterData', ({ target }) => { this.translator.updateNode(target); @@ -83,15 +83,15 @@ export class NodesTranslator { if (attribute === null) return; // NOTE: If need delete untracked nodes, we should keep relates like Element -> attributes - if (!this.translator.isNodeStorageHas(attribute)) { - this.translator.addNode(attribute); + if (!this.translator.hasNode(attribute)) { + this.translator.translateNode(attribute); } else { this.translator.updateNode(attribute); } }); observer.observe(node); - this.translator.addNode(node); + this.translator.translateNode(node); } public unobserve(node: Element) { @@ -99,12 +99,12 @@ export class NodesTranslator { throw new Error('Node is not under observe'); } - this.translator.deleteNode(node); + this.translator.restoreNode(node); this.observedNodesStorage.get(node)?.disconnect(); this.observedNodesStorage.delete(node); } public getNodeData(node: Node) { - return this.translator.getNodeData(node); + return this.translator.getOriginalNodeText(node); } } diff --git a/src/TranslationManager.ts b/src/TranslationDispatcher.ts similarity index 91% rename from src/TranslationManager.ts rename to src/TranslationDispatcher.ts index 7e832f6..c26adff 100644 --- a/src/TranslationManager.ts +++ b/src/TranslationDispatcher.ts @@ -13,7 +13,7 @@ type TranslationManagerConfig = { /** * Class coordinates the processing of DOM nodes for translation. Choose translation strategy: lazy or immediate. */ -export class TranslationManager { +export class TranslationDispatcher { private readonly config: InnerConfig; private readonly domNodeTranslator: DOMTranslator; private readonly lazyTranslator: LazyDOMTranslator; @@ -24,7 +24,7 @@ export class TranslationManager { this.lazyTranslator = lazyTranslator; } - public getNodeData(node: Node) { + public getOriginalNodeText(node: Node) { return this.domNodeTranslator.getOriginalNodeText(node); } @@ -32,18 +32,18 @@ export class TranslationManager { this.domNodeTranslator.updateNode(node); } - public isNodeStorageHas(node: Node) { + public hasNode(node: Node) { return this.domNodeTranslator.hasNode(node); } - public addNode(node: Node) { + public translateNode(node: Node) { // handle all nodes contained within the element (text nodes and attributes of the current and nested elements) if (node instanceof Element) { handleTree(node, (node) => { if (node instanceof Element) return; if (this.config.isTranslatableNode(node)) { - this.addNode(node); + this.translateNode(node); } }); return; @@ -57,7 +57,7 @@ export class TranslationManager { this.domNodeTranslator.translateNode(node); } - public deleteNode(node: Node) { + public restoreNode(node: Node) { this.domNodeTranslator.restoreNode(node); if (node instanceof Element) { From 51cc8c4dfacd88599a32bed5b53467cbca130447 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 12:20:45 +0200 Subject: [PATCH 056/313] refactor: move method logic in another method --- src/TranslationDispatcher.ts | 37 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index c26adff..d9dafa9 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -50,8 +50,22 @@ export class TranslationDispatcher { } // translate later or immediately - if (this.config.lazyTranslate && this.tryLazyTranslate(node)) { - return; + if (this.config.lazyTranslate) { + // Lazy translate when own element intersect viewport + // But translate at once if node have not parent (virtual node) or parent node is outside of body (utility tags like meta or title) + const isAttachedToDOM = node.getRootNode() !== node; + const observableNode = + node instanceof Attr ? node.ownerElement : node.parentElement; + + // Ignore lazy translation for non-intersecting nodes and translate it immediately + if ( + isAttachedToDOM && + observableNode !== null && + isIntersectingNode(observableNode) + ) { + this.lazyTranslator.attach(observableNode); + return; + } } this.domNodeTranslator.translateNode(node); @@ -64,23 +78,4 @@ export class TranslationDispatcher { this.lazyTranslator.detach(node); } } - - private tryLazyTranslate(node: Node) { - // Lazy translate when own element intersect viewport - // But translate at once if node have not parent (virtual node) or parent node is outside of body (utility tags like meta or title) - const isAttachedToDOM = node.getRootNode() !== node; - const observableNode = - node instanceof Attr ? node.ownerElement : node.parentElement; - - // Ignore lazy translation for non-intersecting nodes and translate it immediately - if ( - isAttachedToDOM && - observableNode !== null && - isIntersectingNode(observableNode) - ) { - this.lazyTranslator.attach(observableNode); - return true; - } - return false; - } } From c9a8132a3c7c55f6aa505a9eafa1dbf259668145 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 12:21:30 +0200 Subject: [PATCH 057/313] refactor: delete method --- src/TranslationDispatcher.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index d9dafa9..abaa5d4 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -24,10 +24,6 @@ export class TranslationDispatcher { this.lazyTranslator = lazyTranslator; } - public getOriginalNodeText(node: Node) { - return this.domNodeTranslator.getOriginalNodeText(node); - } - public updateNode(node: Node) { this.domNodeTranslator.updateNode(node); } From b56655f4a83662a2fa7eacfd4b8483495f951477 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 12:22:57 +0200 Subject: [PATCH 058/313] refactor: rename function --- src/DOMTranslator.ts | 4 ++-- src/TranslationDispatcher.ts | 4 ++-- src/__tests__/DomNodeTranslator.test.ts | 4 ++-- src/utils/{handleTree.ts => visitWholeTree.ts} | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) rename src/utils/{handleTree.ts => visitWholeTree.ts} (81%) diff --git a/src/DOMTranslator.ts b/src/DOMTranslator.ts index 1944865..a9ca6bd 100644 --- a/src/DOMTranslator.ts +++ b/src/DOMTranslator.ts @@ -1,5 +1,5 @@ -import { handleTree } from './utils/handleTree'; import { isInViewport } from './utils/isInViewport'; +import { visitWholeTree } from './utils/visitWholeTree'; import { TranslatableNodePredicate, TranslatorInterface } from '.'; interface NodeData { @@ -104,7 +104,7 @@ export class DOMTranslator { public restoreNode(node: Node, onlyTarget = false) { // Delete all attributes and inner nodes if (node instanceof Element && !onlyTarget) { - handleTree(node, (node) => { + visitWholeTree(node, (node) => { this.restoreNode(node, true); }); } diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index abaa5d4..7cebea3 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -1,8 +1,8 @@ import { DOMTranslator } from './DOMTranslator'; import { LazyDOMTranslator } from './LazyDOMTranslator'; import { InnerConfig } from './NodesTranslator'; -import { handleTree } from './utils/handleTree'; import { isIntersectingNode } from './utils/isIntersectingNode'; +import { visitWholeTree } from './utils/visitWholeTree'; type TranslationManagerConfig = { config: InnerConfig; @@ -35,7 +35,7 @@ export class TranslationDispatcher { public translateNode(node: Node) { // handle all nodes contained within the element (text nodes and attributes of the current and nested elements) if (node instanceof Element) { - handleTree(node, (node) => { + visitWholeTree(node, (node) => { if (node instanceof Element) return; if (this.config.isTranslatableNode(node)) { diff --git a/src/__tests__/DomNodeTranslator.test.ts b/src/__tests__/DomNodeTranslator.test.ts index c32230f..afe2876 100644 --- a/src/__tests__/DomNodeTranslator.test.ts +++ b/src/__tests__/DomNodeTranslator.test.ts @@ -1,5 +1,5 @@ import { DOMTranslator } from '../DOMTranslator'; -import { handleTree } from '../utils/handleTree'; +import { visitWholeTree } from '../utils/visitWholeTree'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; describe('DomNodeTranslator base usage', () => { @@ -73,7 +73,7 @@ describe('DomNodeTranslator base usage', () => { const handleElementTree = (node: Node, callback: (node: Node) => void) => { if (node instanceof Element) { - handleTree(node, (node) => { + visitWholeTree(node, (node) => { callback(node); }); } diff --git a/src/utils/handleTree.ts b/src/utils/visitWholeTree.ts similarity index 81% rename from src/utils/handleTree.ts rename to src/utils/visitWholeTree.ts index d2915da..40c1eea 100644 --- a/src/utils/handleTree.ts +++ b/src/utils/visitWholeTree.ts @@ -5,7 +5,7 @@ import { nodeExplore } from './nodeExplore'; * Element, Attr, Text */ -export function handleTree(node: Element, callback: (node: Node) => void) { +export function visitWholeTree(node: Element, callback: (node: Node) => void) { nodeExplore(node, NodeFilter.SHOW_ALL, true, (node) => { callback(node); @@ -13,7 +13,7 @@ export function handleTree(node: Element, callback: (node: Node) => void) { // Handle nodes from opened shadow DOM if (node.shadowRoot !== null) { for (const child of Array.from(node.shadowRoot.children)) { - handleTree(child, callback); + visitWholeTree(child, callback); } } From 4a3ca3c035814f9b55ff8db372a80bd59c0d383d Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 12:24:31 +0200 Subject: [PATCH 059/313] refactor: rename, delete comment --- src/TranslationDispatcher.ts | 4 ++-- src/utils/isIntersectableNode.ts | 5 +++++ src/utils/isIntersectingNode.ts | 6 ------ 3 files changed, 7 insertions(+), 8 deletions(-) create mode 100644 src/utils/isIntersectableNode.ts delete mode 100644 src/utils/isIntersectingNode.ts diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 7cebea3..ad6e2a4 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -1,7 +1,7 @@ import { DOMTranslator } from './DOMTranslator'; import { LazyDOMTranslator } from './LazyDOMTranslator'; import { InnerConfig } from './NodesTranslator'; -import { isIntersectingNode } from './utils/isIntersectingNode'; +import { isIntersectableNode } from './utils/isIntersectableNode'; import { visitWholeTree } from './utils/visitWholeTree'; type TranslationManagerConfig = { @@ -57,7 +57,7 @@ export class TranslationDispatcher { if ( isAttachedToDOM && observableNode !== null && - isIntersectingNode(observableNode) + isIntersectableNode(observableNode) ) { this.lazyTranslator.attach(observableNode); return; diff --git a/src/utils/isIntersectableNode.ts b/src/utils/isIntersectableNode.ts new file mode 100644 index 0000000..2945582 --- /dev/null +++ b/src/utils/isIntersectableNode.ts @@ -0,0 +1,5 @@ +export function isIntersectableNode(node: Element) { + if (node.nodeName === 'OPTION') return false; + + return document.body.contains(node); +} diff --git a/src/utils/isIntersectingNode.ts b/src/utils/isIntersectingNode.ts deleted file mode 100644 index f4b31b8..0000000 --- a/src/utils/isIntersectingNode.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function isIntersectingNode(node: Element) { - // return true for all element not - if (node.nodeName === 'OPTION') return false; - - return document.body.contains(node); -} From 27cd63f4ec6f11775dfbaf1b3caca8eb08f3037a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 12:27:39 +0200 Subject: [PATCH 060/313] chore: rename --- src/utils/visitWholeTree.ts | 4 ++-- src/utils/{nodeExplore.ts => walkNode.ts} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/utils/{nodeExplore.ts => walkNode.ts} (93%) diff --git a/src/utils/visitWholeTree.ts b/src/utils/visitWholeTree.ts index 40c1eea..e51af7c 100644 --- a/src/utils/visitWholeTree.ts +++ b/src/utils/visitWholeTree.ts @@ -1,4 +1,4 @@ -import { nodeExplore } from './nodeExplore'; +import { walkNode } from './walkNode'; /** * Handle all translatable nodes from element @@ -6,7 +6,7 @@ import { nodeExplore } from './nodeExplore'; */ export function visitWholeTree(node: Element, callback: (node: Node) => void) { - nodeExplore(node, NodeFilter.SHOW_ALL, true, (node) => { + walkNode(node, NodeFilter.SHOW_ALL, true, (node) => { callback(node); if (node instanceof Element) { diff --git a/src/utils/nodeExplore.ts b/src/utils/walkNode.ts similarity index 93% rename from src/utils/nodeExplore.ts rename to src/utils/walkNode.ts index a9bcddd..8aee0b5 100644 --- a/src/utils/nodeExplore.ts +++ b/src/utils/walkNode.ts @@ -1,7 +1,7 @@ /** * @param handler if return `false`, loop will stop */ -export const nodeExplore = ( +export const walkNode = ( inputNode: Node, nodeFilter: number, includeSelf: boolean, From 6ea90238008f81cdd766169d9a914e194ec39e54 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 15:29:46 +0200 Subject: [PATCH 061/313] refactor: inline type, rename --- src/TranslationDispatcher.ts | 41 +++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index ad6e2a4..858e121 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -1,35 +1,42 @@ import { DOMTranslator } from './DOMTranslator'; import { LazyDOMTranslator } from './LazyDOMTranslator'; -import { InnerConfig } from './NodesTranslator'; +import { TranslatableNodePredicate } from './NodesTranslator'; import { isIntersectableNode } from './utils/isIntersectableNode'; import { visitWholeTree } from './utils/visitWholeTree'; -type TranslationManagerConfig = { - config: InnerConfig; - domNodeTranslator: DOMTranslator; - lazyTranslator: LazyDOMTranslator; -}; +interface InnerConfig { + isTranslatableNode: TranslatableNodePredicate; + lazyTranslate: boolean; +} /** * Class coordinates the processing of DOM nodes for translation. Choose translation strategy: lazy or immediate. */ export class TranslationDispatcher { private readonly config: InnerConfig; - private readonly domNodeTranslator: DOMTranslator; - private readonly lazyTranslator: LazyDOMTranslator; + private readonly domTranslator: DOMTranslator; + private readonly lazyDOMTranslator: LazyDOMTranslator; - constructor({ config, domNodeTranslator, lazyTranslator }: TranslationManagerConfig) { + constructor({ + config, + domTranslator, + lazyDOMTranslator, + }: { + config: InnerConfig; + domTranslator: DOMTranslator; + lazyDOMTranslator: LazyDOMTranslator; + }) { this.config = config; - this.domNodeTranslator = domNodeTranslator; - this.lazyTranslator = lazyTranslator; + this.domTranslator = domTranslator; + this.lazyDOMTranslator = lazyDOMTranslator; } public updateNode(node: Node) { - this.domNodeTranslator.updateNode(node); + this.domTranslator.updateNode(node); } public hasNode(node: Node) { - return this.domNodeTranslator.hasNode(node); + return this.domTranslator.hasNode(node); } public translateNode(node: Node) { @@ -59,19 +66,19 @@ export class TranslationDispatcher { observableNode !== null && isIntersectableNode(observableNode) ) { - this.lazyTranslator.attach(observableNode); + this.lazyDOMTranslator.attach(observableNode); return; } } - this.domNodeTranslator.translateNode(node); + this.domTranslator.translateNode(node); } public restoreNode(node: Node) { - this.domNodeTranslator.restoreNode(node); + this.domTranslator.restoreNode(node); if (node instanceof Element) { - this.lazyTranslator.detach(node); + this.lazyDOMTranslator.detach(node); } } } From 6e08826c813477f56b280b603740e42f84a2fac9 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 15:43:02 +0200 Subject: [PATCH 062/313] refactor: receive dependency from constructor --- src/NodesTranslator.ts | 61 +++++--------------- src/__tests__/NodesTranslator.test.ts | 83 +++++++++++++++++++++------ 2 files changed, 81 insertions(+), 63 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 7040c1f..8fcfff3 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,16 +1,8 @@ -import { DOMTranslator } from './DOMTranslator'; -import { LazyDOMTranslator } from './LazyDOMTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; import { TranslationDispatcher } from './TranslationDispatcher'; -import { configureTranslatableNodePredicate } from './utils/nodes'; export type TranslatableNodePredicate = (node: Node) => boolean; -export interface InnerConfig { - isTranslatableNode: TranslatableNodePredicate; - lazyTranslate: boolean; -} - export interface Config { isTranslatableNode?: TranslatableNodePredicate; lazyTranslate?: boolean; @@ -26,34 +18,9 @@ export type TranslatorInterface = (text: string, priority: number) => Promise(); public observe(node: Element) { @@ -66,13 +33,13 @@ export class NodesTranslator { this.observedNodesStorage.set(node, observer); observer.addHandler('elementAdded', ({ target }) => - this.translator.translateNode(target), + this.translatorDispatcher.translateNode(target), ); observer.addHandler('elementRemoved', ({ target }) => - this.translator.restoreNode(target), + this.translatorDispatcher.restoreNode(target), ); observer.addHandler('characterData', ({ target }) => { - this.translator.updateNode(target); + this.translatorDispatcher.updateNode(target); }); observer.addHandler('changeAttribute', ({ target, attributeName }) => { if (attributeName === undefined || attributeName === null) return; @@ -83,15 +50,15 @@ export class NodesTranslator { if (attribute === null) return; // NOTE: If need delete untracked nodes, we should keep relates like Element -> attributes - if (!this.translator.hasNode(attribute)) { - this.translator.translateNode(attribute); + if (!this.translatorDispatcher.hasNode(attribute)) { + this.translatorDispatcher.translateNode(attribute); } else { - this.translator.updateNode(attribute); + this.translatorDispatcher.updateNode(attribute); } }); observer.observe(node); - this.translator.translateNode(node); + this.translatorDispatcher.translateNode(node); } public unobserve(node: Element) { @@ -99,12 +66,12 @@ export class NodesTranslator { throw new Error('Node is not under observe'); } - this.translator.restoreNode(node); + this.translatorDispatcher.restoreNode(node); this.observedNodesStorage.get(node)?.disconnect(); this.observedNodesStorage.delete(node); } - public getNodeData(node: Node) { - return this.translator.getOriginalNodeText(node); - } + // public getNodeData(node: Node) { + // return this.domTranslator.getOriginalNodeText(node); + // } } diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 4626a19..56ca8f1 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -1,6 +1,14 @@ import { readFileSync } from 'fs'; -import { Config, NodesTranslator } from '../NodesTranslator'; +import { DOMTranslator } from '../DOMTranslator'; +import { LazyDOMTranslator } from '../LazyDOMTranslator'; +import { + Config, + NodesTranslator, + TranslatableNodePredicate, + TranslatorInterface, +} from '../NodesTranslator'; +import { TranslationDispatcher } from '../TranslationDispatcher'; import { configureTranslatableNodePredicate, NodesFilterOptions } from '../utils/nodes'; require('intersection-observer'); @@ -28,6 +36,38 @@ const fillDocument = (text: string) => { document.write(text); }; +function buildClass( + translateCallback: TranslatorInterface, + config?: { isTranslatableNode?: TranslatableNodePredicate; lazyTranslate?: boolean }, +) { + const innerConfig = { + ...config, + isTranslatableNode: + config?.isTranslatableNode ?? configureTranslatableNodePredicate(), + lazyTranslate: config?.lazyTranslate !== undefined ? config?.lazyTranslate : true, + }; + + const domTranslator = new DOMTranslator( + innerConfig.isTranslatableNode, + translateCallback, + ); + + const lazyDOMTranslator = new LazyDOMTranslator( + innerConfig.isTranslatableNode, + domTranslator.translateNode, + ); + + return { + domTranslator, + + translatorDispatcher: new TranslationDispatcher({ + config: innerConfig, + domTranslator: domTranslator, + lazyDOMTranslator: lazyDOMTranslator, + }), + }; +} + describe('basic usage', () => { const sample = readFileSync(__dirname + '/sample.html', 'utf8'); @@ -41,7 +81,9 @@ describe('basic usage', () => { const parsedHTML = document.documentElement.outerHTML; // Translate document - const domTranslator = new NodesTranslator(translator, { lazyTranslate }); + const domTranslator = new NodesTranslator( + buildClass(translator, { lazyTranslate }).translatorDispatcher, + ); domTranslator.observe(document.documentElement); await awaitTranslation(); @@ -79,6 +121,7 @@ describe('basic usage', () => { 'textarea', ], } satisfies NodesFilterOptions; + const options = { lazyTranslate: isLazyTranslation, isTranslatableNode: configureTranslatableNodePredicate(filterOptions), @@ -89,7 +132,9 @@ describe('basic usage', () => { const parsedHTML = document.documentElement.outerHTML; // Translate document - const domTranslator = new NodesTranslator(translator, options); + const domTranslator = new NodesTranslator( + buildClass(translator, options).translatorDispatcher, + ); domTranslator.observe(document.documentElement); await awaitTranslation(); @@ -104,7 +149,9 @@ describe('basic usage', () => { fillDocument(sample); // Translate document - const domTranslator = new NodesTranslator(translator, options); + const domTranslator = new NodesTranslator( + buildClass(translator, options).translatorDispatcher, + ); domTranslator.observe(document.documentElement); await awaitTranslation(); @@ -158,7 +205,9 @@ describe('basic usage', () => { fillDocument(sample); // Translate document - const domTranslator = new NodesTranslator(translator, options); + const domTranslator = new NodesTranslator( + buildClass(translator, options).translatorDispatcher, + ); const pElm = document.querySelector('p'); const form = document.querySelector('form'); @@ -204,17 +253,19 @@ describe('basic usage', () => { fillDocument(sample); // Translate document - const domTranslator = new NodesTranslator(translator, { - ...options, - isTranslatableNode: configureTranslatableNodePredicate({ - ...filterOptions, - ignoredSelectors: [ - ...filterOptions.ignoredSelectors, - '[translate="no"], .notranslate, [contenteditable], [contenteditable="true"]', - '.custom-elements :checked', - ], - }), - }); + const domTranslator = new NodesTranslator( + buildClass(translator, { + ...options, + isTranslatableNode: configureTranslatableNodePredicate({ + ...filterOptions, + ignoredSelectors: [ + ...filterOptions.ignoredSelectors, + '[translate="no"], .notranslate, [contenteditable], [contenteditable="true"]', + '.custom-elements :checked', + ], + }), + }).translatorDispatcher, + ); domTranslator.observe(document.documentElement); await awaitTranslation(); From 39e320134ff92f409a1a1638058dca2cfd0f664a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 16:03:05 +0200 Subject: [PATCH 063/313] refactor: move types --- src/DOMTranslator.ts | 4 +++- src/LazyDOMTranslator.ts | 2 +- src/NodesTranslator.ts | 9 --------- src/TranslationDispatcher.ts | 13 ++++++------- src/__tests__/NodesTranslator.test.ts | 20 +++++++++++++------- 5 files changed, 23 insertions(+), 25 deletions(-) diff --git a/src/DOMTranslator.ts b/src/DOMTranslator.ts index a9ca6bd..868ab9a 100644 --- a/src/DOMTranslator.ts +++ b/src/DOMTranslator.ts @@ -1,6 +1,8 @@ +import { TranslatableNodePredicate } from './TranslationDispatcher'; import { isInViewport } from './utils/isInViewport'; import { visitWholeTree } from './utils/visitWholeTree'; -import { TranslatableNodePredicate, TranslatorInterface } from '.'; + +export type TranslatorInterface = (text: string, priority: number) => Promise; interface NodeData { /** diff --git a/src/LazyDOMTranslator.ts b/src/LazyDOMTranslator.ts index dd1449c..2dbf402 100644 --- a/src/LazyDOMTranslator.ts +++ b/src/LazyDOMTranslator.ts @@ -1,4 +1,4 @@ -import { TranslatableNodePredicate } from '.'; +import { TranslatableNodePredicate } from './TranslationDispatcher'; /** * Translates nodes only if they intersect the viewport diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 8fcfff3..1dfb01e 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,15 +1,6 @@ import { XMutationObserver } from './lib/XMutationObserver'; import { TranslationDispatcher } from './TranslationDispatcher'; -export type TranslatableNodePredicate = (node: Node) => boolean; - -export interface Config { - isTranslatableNode?: TranslatableNodePredicate; - lazyTranslate?: boolean; -} - -export type TranslatorInterface = (text: string, priority: number) => Promise; - // TODO: consider local language definitions (and implement `from`, `to` parameters for translator to specify default or locale languages) // TODO: scan nodes lazy - defer scan to `requestIdleCallback` instead of instant scan // TODO: describe nodes life cycle diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 858e121..beb252e 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -1,19 +1,15 @@ import { DOMTranslator } from './DOMTranslator'; import { LazyDOMTranslator } from './LazyDOMTranslator'; -import { TranslatableNodePredicate } from './NodesTranslator'; import { isIntersectableNode } from './utils/isIntersectableNode'; import { visitWholeTree } from './utils/visitWholeTree'; -interface InnerConfig { - isTranslatableNode: TranslatableNodePredicate; - lazyTranslate: boolean; -} +export type TranslatableNodePredicate = (node: Node) => boolean; /** * Class coordinates the processing of DOM nodes for translation. Choose translation strategy: lazy or immediate. */ export class TranslationDispatcher { - private readonly config: InnerConfig; + private readonly config; private readonly domTranslator: DOMTranslator; private readonly lazyDOMTranslator: LazyDOMTranslator; @@ -22,7 +18,10 @@ export class TranslationDispatcher { domTranslator, lazyDOMTranslator, }: { - config: InnerConfig; + config: { + isTranslatableNode: TranslatableNodePredicate; + lazyTranslate: boolean; + }; domTranslator: DOMTranslator; lazyDOMTranslator: LazyDOMTranslator; }) { diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 56ca8f1..9e74002 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -1,14 +1,12 @@ import { readFileSync } from 'fs'; -import { DOMTranslator } from '../DOMTranslator'; +import { DOMTranslator, TranslatorInterface } from '../DOMTranslator'; import { LazyDOMTranslator } from '../LazyDOMTranslator'; +import { NodesTranslator } from '../NodesTranslator'; import { - Config, - NodesTranslator, TranslatableNodePredicate, - TranslatorInterface, -} from '../NodesTranslator'; -import { TranslationDispatcher } from '../TranslationDispatcher'; + TranslationDispatcher, +} from '../TranslationDispatcher'; import { configureTranslatableNodePredicate, NodesFilterOptions } from '../utils/nodes'; require('intersection-observer'); @@ -36,9 +34,17 @@ const fillDocument = (text: string) => { document.write(text); }; +interface Config { + isTranslatableNode?: TranslatableNodePredicate; + lazyTranslate?: boolean; +} + function buildClass( translateCallback: TranslatorInterface, - config?: { isTranslatableNode?: TranslatableNodePredicate; lazyTranslate?: boolean }, + config?: { + isTranslatableNode?: TranslatableNodePredicate; + lazyTranslate?: boolean; + }, ) { const innerConfig = { ...config, From 0b61ea128cc2141dacc7904787c7dbbb065de259 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 16:39:36 +0200 Subject: [PATCH 064/313] refactor: add dependency --- src/NodesTranslator.ts | 10 ++++--- src/__tests__/NodesTranslator.test.ts | 41 +++++++++++++++++++++------ 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 1dfb01e..7cc5a49 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,3 +1,4 @@ +import { DOMTranslator } from './DOMTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; import { TranslationDispatcher } from './TranslationDispatcher'; @@ -10,7 +11,8 @@ import { TranslationDispatcher } from './TranslationDispatcher'; */ export class NodesTranslator { constructor( - private readonly translatorDispatcher: TranslationDispatcher, // private readonly domTranslator: DOMTranslator, + private readonly translatorDispatcher: TranslationDispatcher, + private readonly domTranslator: DOMTranslator, ) {} private readonly observedNodesStorage = new Map(); @@ -62,7 +64,7 @@ export class NodesTranslator { this.observedNodesStorage.delete(node); } - // public getNodeData(node: Node) { - // return this.domTranslator.getOriginalNodeText(node); - // } + public getNodeData(node: Node) { + return this.domTranslator.getOriginalNodeText(node); + } } diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 9e74002..35cfd99 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -64,8 +64,7 @@ function buildClass( ); return { - domTranslator, - + domNodeTranslator: domTranslator, translatorDispatcher: new TranslationDispatcher({ config: innerConfig, domTranslator: domTranslator, @@ -87,8 +86,12 @@ describe('basic usage', () => { const parsedHTML = document.documentElement.outerHTML; // Translate document + const { translatorDispatcher, domNodeTranslator } = buildClass(translator, { + lazyTranslate, + }); const domTranslator = new NodesTranslator( - buildClass(translator, { lazyTranslate }).translatorDispatcher, + translatorDispatcher, + domNodeTranslator, ); domTranslator.observe(document.documentElement); @@ -138,8 +141,13 @@ describe('basic usage', () => { const parsedHTML = document.documentElement.outerHTML; // Translate document + const { translatorDispatcher, domNodeTranslator } = buildClass( + translator, + options, + ); const domTranslator = new NodesTranslator( - buildClass(translator, options).translatorDispatcher, + translatorDispatcher, + domNodeTranslator, ); domTranslator.observe(document.documentElement); @@ -155,8 +163,13 @@ describe('basic usage', () => { fillDocument(sample); // Translate document + const { translatorDispatcher, domNodeTranslator } = buildClass( + translator, + options, + ); const domTranslator = new NodesTranslator( - buildClass(translator, options).translatorDispatcher, + translatorDispatcher, + domNodeTranslator, ); domTranslator.observe(document.documentElement); @@ -211,8 +224,13 @@ describe('basic usage', () => { fillDocument(sample); // Translate document + const { translatorDispatcher, domNodeTranslator } = buildClass( + translator, + options, + ); const domTranslator = new NodesTranslator( - buildClass(translator, options).translatorDispatcher, + translatorDispatcher, + domNodeTranslator, ); const pElm = document.querySelector('p'); @@ -259,8 +277,9 @@ describe('basic usage', () => { fillDocument(sample); // Translate document - const domTranslator = new NodesTranslator( - buildClass(translator, { + const { translatorDispatcher, domNodeTranslator } = buildClass( + translator, + { ...options, isTranslatableNode: configureTranslatableNodePredicate({ ...filterOptions, @@ -270,7 +289,11 @@ describe('basic usage', () => { '.custom-elements :checked', ], }), - }).translatorDispatcher, + }, + ); + const domTranslator = new NodesTranslator( + translatorDispatcher, + domNodeTranslator, ); domTranslator.observe(document.documentElement); From 489085f856a3892f5bf94b6d5475478a2654558e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 16:59:57 +0200 Subject: [PATCH 065/313] chore: update export --- src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/index.ts b/src/index.ts index 17c20c4..5d74bcb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,4 @@ export * from './NodesTranslator'; +export * from './TranslationDispatcher'; +export * from './DOMTranslator'; +export * from './LazyDOMTranslator'; From 182e311ba88188f9ce9fc814c569188411ef8b7b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 17:00:51 +0200 Subject: [PATCH 066/313] feat: create default preconfig class --- src/DefaultNodesTranslator.ts | 50 +++++++++++++++++++++++++++++++++++ src/index.ts | 1 + 2 files changed, 51 insertions(+) create mode 100644 src/DefaultNodesTranslator.ts diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts new file mode 100644 index 0000000..b9b61a3 --- /dev/null +++ b/src/DefaultNodesTranslator.ts @@ -0,0 +1,50 @@ +import { NodesTranslator } from './NodesTranslator'; +import { configureTranslatableNodePredicate } from './utils/nodes'; +import { + DOMTranslator, + LazyDOMTranslator, + TranslatableNodePredicate, + TranslationDispatcher, + TranslatorInterface, +} from '.'; + +/** + * Module for dynamic translate a DOM nodes. + * A preconfigured version of {@link NodesTranslator} with all necessary dependencies. + */ +export class DefaultNodesTranslator extends NodesTranslator { + constructor( + translateCallback: TranslatorInterface, + config?: { + isTranslatableNode?: TranslatableNodePredicate; + lazyTranslate?: boolean; + }, + ) { + const innerConfig = { + ...config, + isTranslatableNode: + config?.isTranslatableNode ?? configureTranslatableNodePredicate(), + lazyTranslate: + config?.lazyTranslate !== undefined ? config?.lazyTranslate : true, + }; + + const domTranslator = new DOMTranslator( + innerConfig.isTranslatableNode, + translateCallback, + ); + + const lazyDOMTranslator = new LazyDOMTranslator( + innerConfig.isTranslatableNode, + domTranslator.translateNode, + ); + + super( + new TranslationDispatcher({ + config: innerConfig, + domTranslator: domTranslator, + lazyDOMTranslator: lazyDOMTranslator, + }), + domTranslator, + ); + } +} diff --git a/src/index.ts b/src/index.ts index 5d74bcb..6702e1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,4 @@ export * from './NodesTranslator'; export * from './TranslationDispatcher'; export * from './DOMTranslator'; export * from './LazyDOMTranslator'; +export * from './DefaultNodesTranslator'; From c8d7bb09702951c39e2e0db52e007765d6eb4690 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 17:12:44 +0200 Subject: [PATCH 067/313] refactor: move types --- src/DOMTranslator.ts | 4 +--- src/DefaultNodesTranslator.ts | 17 +++-------------- src/LazyDOMTranslator.ts | 2 +- src/TranslationDispatcher.ts | 3 +-- src/__tests__/NodesTranslator.test.ts | 21 ++++----------------- src/types.ts | 6 ++++++ 6 files changed, 16 insertions(+), 37 deletions(-) create mode 100644 src/types.ts diff --git a/src/DOMTranslator.ts b/src/DOMTranslator.ts index 868ab9a..f219ead 100644 --- a/src/DOMTranslator.ts +++ b/src/DOMTranslator.ts @@ -1,9 +1,7 @@ -import { TranslatableNodePredicate } from './TranslationDispatcher'; +import { TranslatableNodePredicate, TranslatorInterface } from './types'; import { isInViewport } from './utils/isInViewport'; import { visitWholeTree } from './utils/visitWholeTree'; -export type TranslatorInterface = (text: string, priority: number) => Promise; - interface NodeData { /** * Unique node identifier diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index b9b61a3..2bbce03 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -1,25 +1,14 @@ import { NodesTranslator } from './NodesTranslator'; +import { Config, TranslatorInterface } from './types'; import { configureTranslatableNodePredicate } from './utils/nodes'; -import { - DOMTranslator, - LazyDOMTranslator, - TranslatableNodePredicate, - TranslationDispatcher, - TranslatorInterface, -} from '.'; +import { DOMTranslator, LazyDOMTranslator, TranslationDispatcher } from '.'; /** * Module for dynamic translate a DOM nodes. * A preconfigured version of {@link NodesTranslator} with all necessary dependencies. */ export class DefaultNodesTranslator extends NodesTranslator { - constructor( - translateCallback: TranslatorInterface, - config?: { - isTranslatableNode?: TranslatableNodePredicate; - lazyTranslate?: boolean; - }, - ) { + constructor(translateCallback: TranslatorInterface, config?: Config) { const innerConfig = { ...config, isTranslatableNode: diff --git a/src/LazyDOMTranslator.ts b/src/LazyDOMTranslator.ts index 2dbf402..bafbf3d 100644 --- a/src/LazyDOMTranslator.ts +++ b/src/LazyDOMTranslator.ts @@ -1,4 +1,4 @@ -import { TranslatableNodePredicate } from './TranslationDispatcher'; +import { TranslatableNodePredicate } from './types'; /** * Translates nodes only if they intersect the viewport diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index beb252e..a7fc487 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -1,10 +1,9 @@ import { DOMTranslator } from './DOMTranslator'; import { LazyDOMTranslator } from './LazyDOMTranslator'; +import { TranslatableNodePredicate } from './types'; import { isIntersectableNode } from './utils/isIntersectableNode'; import { visitWholeTree } from './utils/visitWholeTree'; -export type TranslatableNodePredicate = (node: Node) => boolean; - /** * Class coordinates the processing of DOM nodes for translation. Choose translation strategy: lazy or immediate. */ diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 35cfd99..d1794d7 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -1,12 +1,10 @@ import { readFileSync } from 'fs'; -import { DOMTranslator, TranslatorInterface } from '../DOMTranslator'; +import { DOMTranslator } from '../DOMTranslator'; import { LazyDOMTranslator } from '../LazyDOMTranslator'; import { NodesTranslator } from '../NodesTranslator'; -import { - TranslatableNodePredicate, - TranslationDispatcher, -} from '../TranslationDispatcher'; +import { TranslationDispatcher } from '../TranslationDispatcher'; +import { Config, TranslatorInterface } from '../types'; import { configureTranslatableNodePredicate, NodesFilterOptions } from '../utils/nodes'; require('intersection-observer'); @@ -34,18 +32,7 @@ const fillDocument = (text: string) => { document.write(text); }; -interface Config { - isTranslatableNode?: TranslatableNodePredicate; - lazyTranslate?: boolean; -} - -function buildClass( - translateCallback: TranslatorInterface, - config?: { - isTranslatableNode?: TranslatableNodePredicate; - lazyTranslate?: boolean; - }, -) { +function buildClass(translateCallback: TranslatorInterface, config?: Config) { const innerConfig = { ...config, isTranslatableNode: diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..3687d90 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,6 @@ +export interface Config { + isTranslatableNode?: TranslatableNodePredicate; + lazyTranslate?: boolean; +} +export type TranslatorInterface = (text: string, priority: number) => Promise; +export type TranslatableNodePredicate = (node: Node) => boolean; From 8942aaff8fa647b1ab7f57ea45c4fe469902a315 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 17:22:44 +0200 Subject: [PATCH 068/313] chore: update api --- src/DOMTranslator.ts | 17 +++++++++++++---- src/DefaultNodesTranslator.ts | 6 +++--- src/__tests__/DomNodeTranslator.test.ts | 5 ++++- src/__tests__/NodesTranslator.test.ts | 6 +++--- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/DOMTranslator.ts b/src/DOMTranslator.ts index f219ead..82d1366 100644 --- a/src/DOMTranslator.ts +++ b/src/DOMTranslator.ts @@ -63,10 +63,19 @@ export class DOMTranslator { private idCounter = 0; private nodeStorage = new WeakMap(); - constructor( - private readonly isTranslatableNode: TranslatableNodePredicate, - private readonly translateCallback: TranslatorInterface, - ) {} + private readonly isTranslatableNode; + private readonly translateCallback; + + constructor({ + isTranslatableNode, + translateCallback, + }: { + isTranslatableNode: TranslatableNodePredicate; + translateCallback: TranslatorInterface; + }) { + this.isTranslatableNode = isTranslatableNode; + this.translateCallback = translateCallback; + } public hasNode(node: Node) { return this.nodeStorage.has(node); diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 2bbce03..14f7c41 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -17,10 +17,10 @@ export class DefaultNodesTranslator extends NodesTranslator { config?.lazyTranslate !== undefined ? config?.lazyTranslate : true, }; - const domTranslator = new DOMTranslator( - innerConfig.isTranslatableNode, + const domTranslator = new DOMTranslator({ + isTranslatableNode: innerConfig.isTranslatableNode, translateCallback, - ); + }); const lazyDOMTranslator = new LazyDOMTranslator( innerConfig.isTranslatableNode, diff --git a/src/__tests__/DomNodeTranslator.test.ts b/src/__tests__/DomNodeTranslator.test.ts index afe2876..125ca75 100644 --- a/src/__tests__/DomNodeTranslator.test.ts +++ b/src/__tests__/DomNodeTranslator.test.ts @@ -12,7 +12,10 @@ describe('DomNodeTranslator base usage', () => { const isTranslatableNode = (node: Node) => node instanceof Text; - domNodeTranslator = new DOMTranslator(isTranslatableNode, translator); + domNodeTranslator = new DOMTranslator({ + isTranslatableNode, + translateCallback: translator, + }); }); test('correct translate element', async () => { diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index d1794d7..7d9bfdb 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -40,10 +40,10 @@ function buildClass(translateCallback: TranslatorInterface, config?: Config) { lazyTranslate: config?.lazyTranslate !== undefined ? config?.lazyTranslate : true, }; - const domTranslator = new DOMTranslator( - innerConfig.isTranslatableNode, + const domTranslator = new DOMTranslator({ + isTranslatableNode: innerConfig.isTranslatableNode, translateCallback, - ); + }); const lazyDOMTranslator = new LazyDOMTranslator( innerConfig.isTranslatableNode, From 974a82cf1d96fef6a22d598e92b8651654c0ab22 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 17:32:11 +0200 Subject: [PATCH 069/313] chore: update api --- src/DefaultNodesTranslator.ts | 14 +++++++------- src/LazyDOMTranslator.ts | 20 +++++++++++++++----- src/__tests__/LazyTranslator.test.ts | 24 ++++++++++++++++-------- src/__tests__/NodesTranslator.test.ts | 8 ++++---- 4 files changed, 42 insertions(+), 24 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 14f7c41..5d81d82 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -22,18 +22,18 @@ export class DefaultNodesTranslator extends NodesTranslator { translateCallback, }); - const lazyDOMTranslator = new LazyDOMTranslator( - innerConfig.isTranslatableNode, - domTranslator.translateNode, - ); + const lazyDOMTranslator = new LazyDOMTranslator({ + isTranslatableNode: innerConfig.isTranslatableNode, + translator: domTranslator.translateNode, + }); - super( - new TranslationDispatcher({ + super({ + translatorDispatcher: new TranslationDispatcher({ config: innerConfig, domTranslator: domTranslator, lazyDOMTranslator: lazyDOMTranslator, }), domTranslator, - ); + }); } } diff --git a/src/LazyDOMTranslator.ts b/src/LazyDOMTranslator.ts index bafbf3d..7d38cd5 100644 --- a/src/LazyDOMTranslator.ts +++ b/src/LazyDOMTranslator.ts @@ -9,13 +9,23 @@ export class LazyDOMTranslator { private readonly nodesObservedForIntersection = new WeakSet(); private readonly intersectionObserver: IntersectionObserver; - constructor( - private readonly isTranslatableNode: TranslatableNodePredicate, - private readonly translator: (node: Node) => void, + private readonly isTranslatableNode; + private readonly translator; + + constructor({ + isTranslatableNode, + translator, + config, + }: { + isTranslatableNode: TranslatableNodePredicate; + translator: (node: Node) => void; config?: { intersectionConfig?: IntersectionObserverInit; - }, - ) { + }; + }) { + this.isTranslatableNode = isTranslatableNode; + this.translator = translator; + this.intersectionObserver = new IntersectionObserver((entries, observer) => { entries.forEach((entry) => { const node = entry.target; diff --git a/src/__tests__/LazyTranslator.test.ts b/src/__tests__/LazyTranslator.test.ts index 046505e..0926bd3 100644 --- a/src/__tests__/LazyTranslator.test.ts +++ b/src/__tests__/LazyTranslator.test.ts @@ -21,7 +21,7 @@ describe('LazyTranslator base usage', () => { }); test('translate element at intersection', async () => { - const lazyTranslator = new LazyDOMTranslator(isTranslatableNode, translator); + const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); lazyTranslator.attach(divElement); await awaitTranslation(); @@ -35,9 +35,13 @@ describe('LazyTranslator base usage', () => { }); test('translate node that intersect the custom ancestor', async () => { - const lazyTranslator = new LazyDOMTranslator(isTranslatableNode, translator, { - intersectionConfig: { - root: divElement, + const lazyTranslator = new LazyDOMTranslator({ + isTranslatableNode, + translator, + config: { + intersectionConfig: { + root: divElement, + }, }, }); lazyTranslator.attach(divElement); @@ -52,7 +56,7 @@ describe('LazyTranslator base usage', () => { }); test('not translate nodes that not intersected', async () => { - const lazyTranslator = new LazyDOMTranslator(isTranslatableNode, translator); + const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); const newDivElement = document.createElement('div'); @@ -66,9 +70,13 @@ describe('LazyTranslator base usage', () => { test('not translate node that not intersect the custom ancestor', async () => { const divElement = document.createElement('div'); - const lazyTranslator = new LazyDOMTranslator(isTranslatableNode, translator, { - intersectionConfig: { - root: divElement, + const lazyTranslator = new LazyDOMTranslator({ + isTranslatableNode, + translator, + config: { + intersectionConfig: { + root: divElement, + }, }, }); diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 7d9bfdb..7609e36 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -45,10 +45,10 @@ function buildClass(translateCallback: TranslatorInterface, config?: Config) { translateCallback, }); - const lazyDOMTranslator = new LazyDOMTranslator( - innerConfig.isTranslatableNode, - domTranslator.translateNode, - ); + const lazyDOMTranslator = new LazyDOMTranslator({ + isTranslatableNode: innerConfig.isTranslatableNode, + translator: domTranslator.translateNode, + }); return { domNodeTranslator: domTranslator, From 0a0621a97a394b388ebd2d6682d7a1b7c6c32791 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 17:35:50 +0200 Subject: [PATCH 070/313] chore: update api --- src/NodesTranslator.ts | 17 +++++++++++---- src/__tests__/NodesTranslator.test.ts | 30 +++++++++++++-------------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 7cc5a49..ed00ab9 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -10,10 +10,19 @@ import { TranslationDispatcher } from './TranslationDispatcher'; * Module for dynamic translate a DOM nodes */ export class NodesTranslator { - constructor( - private readonly translatorDispatcher: TranslationDispatcher, - private readonly domTranslator: DOMTranslator, - ) {} + private readonly translatorDispatcher; + private readonly domTranslator; + + constructor({ + translatorDispatcher, + domTranslator, + }: { + translatorDispatcher: TranslationDispatcher; + domTranslator: DOMTranslator; + }) { + this.translatorDispatcher = translatorDispatcher; + this.domTranslator = domTranslator; + } private readonly observedNodesStorage = new Map(); public observe(node: Element) { diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 7609e36..ae8b2b3 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -76,10 +76,10 @@ describe('basic usage', () => { const { translatorDispatcher, domNodeTranslator } = buildClass(translator, { lazyTranslate, }); - const domTranslator = new NodesTranslator( + const domTranslator = new NodesTranslator({ translatorDispatcher, - domNodeTranslator, - ); + domTranslator: domNodeTranslator, + }); domTranslator.observe(document.documentElement); await awaitTranslation(); @@ -132,10 +132,10 @@ describe('basic usage', () => { translator, options, ); - const domTranslator = new NodesTranslator( + const domTranslator = new NodesTranslator({ translatorDispatcher, - domNodeTranslator, - ); + domTranslator: domNodeTranslator, + }); domTranslator.observe(document.documentElement); await awaitTranslation(); @@ -154,10 +154,10 @@ describe('basic usage', () => { translator, options, ); - const domTranslator = new NodesTranslator( + const domTranslator = new NodesTranslator({ translatorDispatcher, - domNodeTranslator, - ); + domTranslator: domNodeTranslator, + }); domTranslator.observe(document.documentElement); await awaitTranslation(); @@ -215,10 +215,10 @@ describe('basic usage', () => { translator, options, ); - const domTranslator = new NodesTranslator( + const domTranslator = new NodesTranslator({ translatorDispatcher, - domNodeTranslator, - ); + domTranslator: domNodeTranslator, + }); const pElm = document.querySelector('p'); const form = document.querySelector('form'); @@ -278,10 +278,10 @@ describe('basic usage', () => { }), }, ); - const domTranslator = new NodesTranslator( + const domTranslator = new NodesTranslator({ translatorDispatcher, - domNodeTranslator, - ); + domTranslator: domNodeTranslator, + }); domTranslator.observe(document.documentElement); await awaitTranslation(); From d51e46f0ab6efcfca7331a00b3a6a7610c15da07 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 17:46:19 +0200 Subject: [PATCH 071/313] test: update mock --- src/__tests__/LazyTranslator.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/__tests__/LazyTranslator.test.ts b/src/__tests__/LazyTranslator.test.ts index 0926bd3..55c2364 100644 --- a/src/__tests__/LazyTranslator.test.ts +++ b/src/__tests__/LazyTranslator.test.ts @@ -3,11 +3,11 @@ import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL } from './utils'; require('intersection-observer'); -const translator = vi.fn().mockImplementation(async (node: Text) => { - return (node.textContent = TRANSLATION_SYMBOL + node.textContent); +const translator = vi.fn().mockImplementation(async (node: Node) => { + node.textContent += TRANSLATION_SYMBOL; }); -const isTranslatableNode = (node: Node) => node instanceof Text; +const isTranslatableNode = (node: Node) => node instanceof Text || node instanceof Attr; describe('LazyTranslator base usage', () => { const divElement = document.createElement('div'); From ec0254ed94b3ecff20780ee84eb1b235babb6eb2 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 26 Apr 2025 23:11:28 +0200 Subject: [PATCH 072/313] test: update test case --- src/__tests__/LazyTranslator.test.ts | 148 +++++++++++++-------------- 1 file changed, 69 insertions(+), 79 deletions(-) diff --git a/src/__tests__/LazyTranslator.test.ts b/src/__tests__/LazyTranslator.test.ts index 55c2364..cfbd7ab 100644 --- a/src/__tests__/LazyTranslator.test.ts +++ b/src/__tests__/LazyTranslator.test.ts @@ -9,83 +9,73 @@ const translator = vi.fn().mockImplementation(async (node: Node) => { const isTranslatableNode = (node: Node) => node instanceof Text || node instanceof Attr; -describe('LazyTranslator base usage', () => { - const divElement = document.createElement('div'); - const textNode = document.createTextNode('Hello, World!'); - - divElement.appendChild(textNode); - document.body.appendChild(divElement); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - test('translate element at intersection', async () => { - const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); - - lazyTranslator.attach(divElement); - await awaitTranslation(); - - // The mock function was called ones - expect(translator.mock.calls).toHaveLength(1); - expect(translator).toHaveBeenCalledWith(textNode); - - // the node translate lazy - expect(textNode.textContent).toMatchObject(containsRegex(TRANSLATION_SYMBOL)); - }); - - test('translate node that intersect the custom ancestor', async () => { - const lazyTranslator = new LazyDOMTranslator({ - isTranslatableNode, - translator, - config: { - intersectionConfig: { - root: divElement, - }, - }, - }); - lazyTranslator.attach(divElement); - await awaitTranslation(); - - // The mock function was called ones - expect(translator.mock.calls).toHaveLength(1); - expect(translator).toHaveBeenCalledWith(textNode); - - // the node translate lazy - expect(textNode.textContent).toMatchObject(containsRegex(TRANSLATION_SYMBOL)); - }); - - test('not translate nodes that not intersected', async () => { - const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); - - const newDivElement = document.createElement('div'); - - lazyTranslator.attach(newDivElement); - await awaitTranslation(); - - // The mock function was not called - expect(translator.mock.calls).toHaveLength(0); - expect(newDivElement.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - }); - - test('not translate node that not intersect the custom ancestor', async () => { - const divElement = document.createElement('div'); - const lazyTranslator = new LazyDOMTranslator({ - isTranslatableNode, - translator, - config: { - intersectionConfig: { - root: divElement, - }, - }, - }); - - const newDivElement = document.createElement('div'); - - lazyTranslator.attach(newDivElement); - await awaitTranslation(); - - expect(translator.mock.calls).toHaveLength(0); - expect(newDivElement.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - }); +beforeEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); +}); + +test('Translate element from viewport', async () => { + const div = document.createElement('div'); + div.innerHTML = 'Hello, World!'; + document.body.appendChild(div); + + const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); + + lazyTranslator.attach(div); + await awaitTranslation(); + + // The mock function was called ones + expect(translator.mock.calls).toHaveLength(1); + expect(translator).toHaveBeenCalledWith(div.childNodes[0]); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); +}); + +test('Translate one element twice', async () => { + const div = document.createElement('div'); + div.innerHTML = 'Hello, World!'; + document.body.appendChild(div); + + const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); + lazyTranslator.attach(div); + await awaitTranslation(); + + expect(translator.mock.calls).toHaveLength(1); + expect(translator).toHaveBeenCalledWith(div.childNodes[0]); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // update element content + const updatedText = 'Hello, World 12345!'; + div.innerHTML = updatedText; + + lazyTranslator.attach(div); + await awaitTranslation(); + + // translated text contains translated symbols and updated text + expect(div.textContent).toMatch(updatedText); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); +}); + +test('Does not translate elements if they are not attached to the DOM or not visible', async () => { + const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); + + // element not attach to DOM + const div = document.createElement('div'); + div.innerHTML = 'Hello, world'; + + lazyTranslator.attach(div); + await awaitTranslation(); + + expect(translator.mock.calls).toHaveLength(0); + expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // Attach to the DOM; elements with display = 'none' should not be intersectable + document.body.appendChild(div); + // Hidden: Element with the visible property is considered intersectable, so use the display property instead. + div.style.display = 'none'; + + lazyTranslator.attach(div); + await awaitTranslation(); + + expect(translator.mock.calls).toHaveLength(0); + expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); From 01324662d081050b81064e77ffc370c1cadc02d5 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 27 Apr 2025 00:24:39 +0200 Subject: [PATCH 073/313] test: improve test --- src/__tests__/DomNodeTranslator.test.ts | 269 ++++++++++++++---------- 1 file changed, 162 insertions(+), 107 deletions(-) diff --git a/src/__tests__/DomNodeTranslator.test.ts b/src/__tests__/DomNodeTranslator.test.ts index 125ca75..30cf5a4 100644 --- a/src/__tests__/DomNodeTranslator.test.ts +++ b/src/__tests__/DomNodeTranslator.test.ts @@ -1,148 +1,203 @@ import { DOMTranslator } from '../DOMTranslator'; -import { visitWholeTree } from '../utils/visitWholeTree'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; -describe('DomNodeTranslator base usage', () => { - let domNodeTranslator: DOMTranslator; - let div: Element; - - beforeEach(() => { - div = document.createElement('div'); - div.innerHTML = 'Hello world!'; - - const isTranslatableNode = (node: Node) => node instanceof Text; +beforeEach(() => { + document.body.innerHTML = ''; +}); - domNodeTranslator = new DOMTranslator({ - isTranslatableNode, - translateCallback: translator, - }); +test('Translate and restore original element text', async () => { + const domTranslator = new DOMTranslator({ + isTranslatableNode: Boolean, + translateCallback: translator, }); - test('correct translate element', async () => { - const div = document.createElement('div'); - const originalText = 'Hello world!'; - div.innerHTML = originalText; + const originElementText = 'Hello world!'; + const div = document.createElement('div'); + div.innerHTML = originElementText; - domNodeTranslator.translateNode(div.childNodes[0]); - await awaitTranslation(); + domTranslator.translateNode(div.childNodes[0]); + await awaitTranslation(); - expect(div.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); - // disable translation - domNodeTranslator.restoreNode(div.childNodes[0]); - expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + // disable translation + domTranslator.restoreNode(div.childNodes[0]); + expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.innerHTML).toMatch(originElementText); +}); + +test('Get original node text', async () => { + const domTranslator = new DOMTranslator({ + isTranslatableNode: Boolean, + translateCallback: translator, }); - test('getNodeData returns the original text', async () => { - const originalText = 'Hello world!'; + const originElementText = 'Hello world!'; + const div = document.createElement('div'); + div.innerHTML = originElementText; - // translate - domNodeTranslator.translateNode(div.childNodes[0]); - await awaitTranslation(); + // translate + domTranslator.translateNode(div.childNodes[0]); + await awaitTranslation(); - expect(domNodeTranslator.getOriginalNodeText(div.childNodes[0])).toEqual( - expect.objectContaining({ - originalText: originalText, - }), - ); + expect(domTranslator.getOriginalNodeText(div.childNodes[0])).toEqual( + originElementText, + ); +}); + +test('Not translate empty element', async () => { + const domTranslator = new DOMTranslator({ + isTranslatableNode: Boolean, + translateCallback: translator, }); - test('not translate empty element', async () => { - div.innerHTML = ' '; + const div = document.createElement('div'); + div.innerHTML = ' '; - // translate - domNodeTranslator.translateNode(div.childNodes[0]); - await awaitTranslation(); + // translate + domTranslator.translateNode(div.childNodes[0]); + await awaitTranslation(); - expect(div.childNodes[0].textContent).not.toMatch( - containsRegex(TRANSLATION_SYMBOL), - ); + expect(div.childNodes[0].textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); +}); + +test('Checks existing element in storage', async () => { + const domTranslator = new DOMTranslator({ + isTranslatableNode: Boolean, + translateCallback: translator, }); + const div = document.createElement('div'); + div.innerHTML = 'Hello world!'; - test('isNodeStorageHas returns correct result', async () => { - domNodeTranslator.translateNode(div.childNodes[0]); - await awaitTranslation(); + domTranslator.translateNode(div.childNodes[0]); + await awaitTranslation(); + + expect(domTranslator.hasNode(div.childNodes[0])).toBe(true); - expect(domNodeTranslator.hasNode(div.childNodes[0])).toBe(true); + //delete element + domTranslator.restoreNode(div.childNodes[0]); + expect(domTranslator.hasNode(div.childNodes[0])).toBe(false); +}); - //delete element - domNodeTranslator.restoreNode(div.childNodes[0]); - expect(domNodeTranslator.hasNode(div.childNodes[0])).toBe(false); +test('Update translation for element ', async () => { + const domTranslator = new DOMTranslator({ + isTranslatableNode: Boolean, + translateCallback: translator, }); + const div = document.createElement('div'); + const originalText = 'Hello world!'; + div.innerHTML = originalText; + + // spy on the updateNode method + const updateNodesSpy = vi.spyOn(domTranslator as DOMTranslator, 'updateNode'); + + // translate element + domTranslator.translateNode(div.childNodes[0]); + await awaitTranslation(); + + // update element + const newText = 'Goodbye world!'; + div.innerHTML = newText; + domTranslator.translateNode(div.childNodes[0]); + await awaitTranslation(); + + domTranslator.updateNode(div.childNodes[0]); + await awaitTranslation(); + + // correct text translation + expect(div.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.innerHTML).toMatch(newText); + + // update calls one time + expect(updateNodesSpy).toBeCalledTimes(1); + expect(updateNodesSpy.mock.calls[0][0]).toMatchObject( + containsRegex(TRANSLATION_SYMBOL), + ); +}); - describe('DeleteNode', () => { - let parentDiv: Element; - let childDiv: Element; - - const handleElementTree = (node: Node, callback: (node: Node) => void) => { - if (node instanceof Element) { - visitWholeTree(node, (node) => { - callback(node); - }); - } - }; - - beforeEach(() => { - parentDiv = document.createElement('div'); - parentDiv.innerHTML = 'Hello world!'; - - childDiv = document.createElement('div'); - childDiv.innerHTML = 'Hello world too!'; - parentDiv.append(childDiv); +describe('Restore node', () => { + // mock for to translate the entire element tree + const handleElementTree = (node: Node, callback: (node: Node) => void) => { + if (node instanceof Element) { + vi.fn((root: Element, callback: (n: Node) => void) => { + const handel = (n: Node) => { + callback(n); + if (n instanceof Element) { + Array.from(n.childNodes).forEach(handel); + Array.from(n.attributes).forEach(callback); + } + }; + handel(root); + })(node, (node) => { + callback(node); + }); + } + }; + + test('Restore the text element after a few translations', async () => { + const domTranslator = new DOMTranslator({ + isTranslatableNode: Boolean, + translateCallback: translator, }); + const div = document.createElement('div'); + div.innerHTML = 'Hello world!'; + domTranslator.translateNode(div.childNodes[0]); + await awaitTranslation(); - test('delete translations from all nodes in the tree', async () => { - handleElementTree(parentDiv, domNodeTranslator.translateNode); - await awaitTranslation(); + // update text + const newText = 'Hello world 1234!'; + div.innerHTML = newText; + domTranslator.translateNode(div.childNodes[0]); + await awaitTranslation(); - domNodeTranslator.restoreNode(parentDiv); + // restore + domTranslator.restoreNode(div.childNodes[0]); + expect(div.innerHTML).toMatch(newText); + expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + }); - // child node and target has not translated text - expect(parentDiv.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(childDiv.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + test('Restore translations from all nested nodes in the element', async () => { + const domTranslator = new DOMTranslator({ + isTranslatableNode: Boolean, + translateCallback: translator, }); + const parentDiv = document.createElement('div'); + parentDiv.innerHTML = 'Hello world!'; + const childDiv = document.createElement('div'); + childDiv.innerHTML = 'Hello world too!'; + parentDiv.append(childDiv); - test('delete translation from the selected node', async () => { - handleElementTree(parentDiv, domNodeTranslator.translateNode); - await awaitTranslation(); + handleElementTree(parentDiv, domTranslator.translateNode); + await awaitTranslation(); - domNodeTranslator.restoreNode(parentDiv.childNodes[0], true); + domTranslator.restoreNode(parentDiv); - //target element has not translation - expect(parentDiv.childNodes[0].textContent).not.toMatch( - containsRegex(TRANSLATION_SYMBOL), - ); - // child element still has translation - expect(childDiv.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); - }); + // child node and target has not translated text + expect(parentDiv.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(childDiv.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); - test('updateNode should be call ones', async () => { - const div = document.createElement('div'); - const originalText = 'Hello world!'; - div.innerHTML = originalText; - - // spy on the updateNode method - const updateNodesSpy = vi.spyOn(domNodeTranslator as DOMTranslator, 'updateNode'); - - // translate element - domNodeTranslator.translateNode(div.childNodes[0]); - await awaitTranslation(); - - // update element - const newText = 'Goodbye world!'; - div.innerHTML = newText; + test('Delete translation only from target element', async () => { + const domTranslator = new DOMTranslator({ + isTranslatableNode: Boolean, + translateCallback: translator, + }); + const parentDiv = document.createElement('div'); + parentDiv.innerHTML = 'Hello world!'; + const childDiv = document.createElement('div'); + childDiv.innerHTML = 'Hello world too!'; + parentDiv.append(childDiv); - domNodeTranslator.translateNode(div.childNodes[0]); + handleElementTree(parentDiv, domTranslator.translateNode); await awaitTranslation(); - domNodeTranslator.updateNode(div.childNodes[0]); - await awaitTranslation(); + domTranslator.restoreNode(parentDiv.childNodes[0], true); - expect(div.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(updateNodesSpy).toBeCalledTimes(1); - expect(updateNodesSpy.mock.calls[0][0]).toMatchObject( + //target element has not translation + expect(parentDiv.childNodes[0].textContent).not.toMatch( containsRegex(TRANSLATION_SYMBOL), ); + // child element still has translation + expect(childDiv.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); }); From 023898cb80a167031244e19edf3cb9e1438ed2b0 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 27 Apr 2025 00:26:06 +0200 Subject: [PATCH 074/313] test: remove unnecessary code --- src/__tests__/DomNodeTranslator.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/__tests__/DomNodeTranslator.test.ts b/src/__tests__/DomNodeTranslator.test.ts index 30cf5a4..d977b18 100644 --- a/src/__tests__/DomNodeTranslator.test.ts +++ b/src/__tests__/DomNodeTranslator.test.ts @@ -1,10 +1,6 @@ import { DOMTranslator } from '../DOMTranslator'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; -beforeEach(() => { - document.body.innerHTML = ''; -}); - test('Translate and restore original element text', async () => { const domTranslator = new DOMTranslator({ isTranslatableNode: Boolean, From a36ca006c2a54381162c249a50717d29235bd0d7 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 27 Apr 2025 00:41:00 +0200 Subject: [PATCH 075/313] test: add test case --- src/__tests__/LazyTranslator.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/__tests__/LazyTranslator.test.ts b/src/__tests__/LazyTranslator.test.ts index cfbd7ab..0d5dca6 100644 --- a/src/__tests__/LazyTranslator.test.ts +++ b/src/__tests__/LazyTranslator.test.ts @@ -79,3 +79,25 @@ test('Does not translate elements if they are not attached to the DOM or not vis expect(translator.mock.calls).toHaveLength(0); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); + +test('Not translate element after detach', async () => { + const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); + // element not attach to DOM + const div = document.createElement('div'); + div.innerHTML = 'Hello world!'; + div.style.display = 'none'; + document.body.appendChild(div); + + lazyTranslator.attach(div); + await awaitTranslation(); + // not translate + expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // after display element on node element still not translate + lazyTranslator.detach(div); + + // attach element to DOM + div.style.display = 'block'; + + expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); +}); From dd647a11599c382f1f8e2b30eab2ba693dc4f5c8 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 27 Apr 2025 00:46:04 +0200 Subject: [PATCH 076/313] chore: rename --- .../{DomNodeTranslator.test.ts => DOMTranslator.test.ts} | 0 .../{LazyTranslator.test.ts => LazyDOMTranslator.test.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/__tests__/{DomNodeTranslator.test.ts => DOMTranslator.test.ts} (100%) rename src/__tests__/{LazyTranslator.test.ts => LazyDOMTranslator.test.ts} (100%) diff --git a/src/__tests__/DomNodeTranslator.test.ts b/src/__tests__/DOMTranslator.test.ts similarity index 100% rename from src/__tests__/DomNodeTranslator.test.ts rename to src/__tests__/DOMTranslator.test.ts diff --git a/src/__tests__/LazyTranslator.test.ts b/src/__tests__/LazyDOMTranslator.test.ts similarity index 100% rename from src/__tests__/LazyTranslator.test.ts rename to src/__tests__/LazyDOMTranslator.test.ts From 25da71f7bb99975690323d4450d307ad14e52dbc Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 27 Apr 2025 21:58:44 +0200 Subject: [PATCH 077/313] test: add test case --- src/__tests__/LazyDOMTranslator.test.ts | 81 +++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/__tests__/LazyDOMTranslator.test.ts b/src/__tests__/LazyDOMTranslator.test.ts index 0d5dca6..f4d0949 100644 --- a/src/__tests__/LazyDOMTranslator.test.ts +++ b/src/__tests__/LazyDOMTranslator.test.ts @@ -101,3 +101,84 @@ test('Not translate element after detach', async () => { expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); + +describe('LazyDOMTranslator with Polyfill triggered by scroll event', () => { + const mockBoundingClientRect = (element: HTMLElement, rect: Partial) => { + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: vi.fn(() => ({ + top: 0, + left: 0, + bottom: 0, + right: 0, + width: 0, + height: 0, + x: 0, + y: 0, + ...rect, + toJSON: () => JSON.stringify(rect), + })), + }); + }; + + test('Translate an element only after it appears in the viewport', async () => { + const container = document.createElement('div'); + const div = document.createElement('div'); + div.innerHTML = 'Hello world!'; + container.appendChild(div); + document.body.appendChild(container); + + container.style.width = '300px'; + container.style.height = '300px'; + + mockBoundingClientRect(container, { + top: 0, + left: 0, + bottom: 300, + right: 300, + width: 300, + height: 300, + }); + + // element out of viewport, it not intersect container + mockBoundingClientRect(div, { + top: 400, + left: 0, + bottom: 500, + right: 100, + width: 100, + height: 100, + }); + + const lazyTranslator = new LazyDOMTranslator({ + isTranslatableNode, + translator, + config: { intersectionConfig: { root: container } }, + }); + + lazyTranslator.attach(div); + await awaitTranslation(); + + // don't translate because the element doesn't intersect the container + expect(translator).not.toHaveBeenCalled(); + expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // change coordinates, now element in viewport + mockBoundingClientRect(div, { + top: 100, + left: 0, + bottom: 200, + right: 100, + width: 100, + height: 100, + }); + + // simulates the scroll event, and the polyfill listens for the "scroll" event in the document + // the scroll event triggers an intersection check + document.dispatchEvent(new Event('scroll', { bubbles: true })); + + await awaitTranslation(); + expect(translator).toHaveBeenCalledTimes(1); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + }); +}); From 4a1d204450365c6f20b351ca016947805f58117b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 27 Apr 2025 22:03:36 +0200 Subject: [PATCH 078/313] test: improve comment and test name --- src/__tests__/LazyDOMTranslator.test.ts | 163 ++++++++++++------------ 1 file changed, 81 insertions(+), 82 deletions(-) diff --git a/src/__tests__/LazyDOMTranslator.test.ts b/src/__tests__/LazyDOMTranslator.test.ts index f4d0949..63c9d35 100644 --- a/src/__tests__/LazyDOMTranslator.test.ts +++ b/src/__tests__/LazyDOMTranslator.test.ts @@ -9,6 +9,26 @@ const translator = vi.fn().mockImplementation(async (node: Node) => { const isTranslatableNode = (node: Node) => node instanceof Text || node instanceof Attr; +// jsdom does not actually modify element coordinates +// Create a mock that sets the real values for the coordinates +const mockBoundingClientRect = (element: HTMLElement, rect: Partial) => { + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: vi.fn(() => ({ + top: 0, + left: 0, + bottom: 0, + right: 0, + width: 0, + height: 0, + x: 0, + y: 0, + ...rect, + toJSON: () => JSON.stringify(rect), + })), + }); +}; + beforeEach(() => { document.body.innerHTML = ''; vi.clearAllMocks(); @@ -90,95 +110,74 @@ test('Not translate element after detach', async () => { lazyTranslator.attach(div); await awaitTranslation(); - // not translate + // not translate because element not visible expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - // after display element on node element still not translate + // after the element becomes visible, it still isn't translated lazyTranslator.detach(div); - - // attach element to DOM + // visible element div.style.display = 'block'; expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -describe('LazyDOMTranslator with Polyfill triggered by scroll event', () => { - const mockBoundingClientRect = (element: HTMLElement, rect: Partial) => { - Object.defineProperty(element, 'getBoundingClientRect', { - configurable: true, - value: vi.fn(() => ({ - top: 0, - left: 0, - bottom: 0, - right: 0, - width: 0, - height: 0, - x: 0, - y: 0, - ...rect, - toJSON: () => JSON.stringify(rect), - })), - }); - }; - - test('Translate an element only after it appears in the viewport', async () => { - const container = document.createElement('div'); - const div = document.createElement('div'); - div.innerHTML = 'Hello world!'; - container.appendChild(div); - document.body.appendChild(container); - - container.style.width = '300px'; - container.style.height = '300px'; - - mockBoundingClientRect(container, { - top: 0, - left: 0, - bottom: 300, - right: 300, - width: 300, - height: 300, - }); - - // element out of viewport, it not intersect container - mockBoundingClientRect(div, { - top: 400, - left: 0, - bottom: 500, - right: 100, - width: 100, - height: 100, - }); - - const lazyTranslator = new LazyDOMTranslator({ - isTranslatableNode, - translator, - config: { intersectionConfig: { root: container } }, - }); - - lazyTranslator.attach(div); - await awaitTranslation(); - - // don't translate because the element doesn't intersect the container - expect(translator).not.toHaveBeenCalled(); - expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - - // change coordinates, now element in viewport - mockBoundingClientRect(div, { - top: 100, - left: 0, - bottom: 200, - right: 100, - width: 100, - height: 100, - }); - - // simulates the scroll event, and the polyfill listens for the "scroll" event in the document - // the scroll event triggers an intersection check - document.dispatchEvent(new Event('scroll', { bubbles: true })); - - await awaitTranslation(); - expect(translator).toHaveBeenCalledTimes(1); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); +test('Translate element only after it appears in the viewport', async () => { + const container = document.createElement('div'); + const div = document.createElement('div'); + div.innerHTML = 'Hello world!'; + container.appendChild(div); + document.body.appendChild(container); + + container.style.width = '300px'; + container.style.height = '300px'; + + mockBoundingClientRect(container, { + top: 0, + left: 0, + bottom: 300, + right: 300, + width: 300, + height: 300, + }); + + // element out of viewport, it not intersect container + mockBoundingClientRect(div, { + top: 400, + left: 0, + bottom: 500, + right: 100, + width: 100, + height: 100, }); + + const lazyTranslator = new LazyDOMTranslator({ + isTranslatableNode, + translator, + config: { intersectionConfig: { root: container } }, + }); + + lazyTranslator.attach(div); + await awaitTranslation(); + + // don't translate because the element doesn't intersect the container + expect(translator).not.toHaveBeenCalled(); + expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // change coordinates, now element in viewport + mockBoundingClientRect(div, { + top: 100, + left: 0, + bottom: 200, + right: 100, + width: 100, + height: 100, + }); + + // simulates the scroll event, and the polyfill listens for the "scroll" event in the document + // the scroll event triggers an intersection check + document.dispatchEvent(new Event('scroll', { bubbles: true })); + + await awaitTranslation(); + expect(translator).toHaveBeenCalledTimes(1); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); From 5dba42c8f1836f6932676401c05aedcd80f24ab5 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 27 Apr 2025 22:08:07 +0200 Subject: [PATCH 079/313] test: add test case --- src/__tests__/LazyDOMTranslator.test.ts | 62 +++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/__tests__/LazyDOMTranslator.test.ts b/src/__tests__/LazyDOMTranslator.test.ts index 63c9d35..f1dbd0e 100644 --- a/src/__tests__/LazyDOMTranslator.test.ts +++ b/src/__tests__/LazyDOMTranslator.test.ts @@ -181,3 +181,65 @@ test('Translate element only after it appears in the viewport', async () => { expect(translator).toHaveBeenCalledTimes(1); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); + +test('Not translate the element if it is still not in the viewport after scrolling', async () => { + const container = document.createElement('div'); + const div = document.createElement('div'); + div.innerHTML = 'Hello world!'; + container.appendChild(div); + document.body.appendChild(container); + + container.style.width = '300px'; + container.style.height = '300px'; + + mockBoundingClientRect(container, { + top: 0, + left: 0, + bottom: 300, + right: 300, + width: 300, + height: 300, + }); + + // element out of viewport, it not intersect container + mockBoundingClientRect(div, { + top: 400, + left: 0, + bottom: 500, + right: 100, + width: 100, + height: 100, + }); + + const lazyTranslator = new LazyDOMTranslator({ + isTranslatableNode, + translator, + config: { intersectionConfig: { root: container } }, + }); + + lazyTranslator.attach(div); + await awaitTranslation(); + + // don't translate because the element doesn't intersect the container + expect(translator).not.toHaveBeenCalled(); + expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // change coordinates, now element in viewport + mockBoundingClientRect(div, { + top: 330, + left: 0, + bottom: 200, + right: 100, + width: 100, + height: 100, + }); + + // simulates the scroll event, and the polyfill listens for the "scroll" event in the document + // the scroll event triggers an intersection check + document.dispatchEvent(new Event('scroll', { bubbles: true })); + await awaitTranslation(); + + // still have not translate + expect(translator).toHaveBeenCalledTimes(0); + expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); +}); From 5d193d8f4bf96cccdd09f59050720ef58d079ccc Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 27 Apr 2025 22:21:07 +0200 Subject: [PATCH 080/313] test: improve test case --- src/__tests__/LazyDOMTranslator.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/__tests__/LazyDOMTranslator.test.ts b/src/__tests__/LazyDOMTranslator.test.ts index f1dbd0e..87c81ff 100644 --- a/src/__tests__/LazyDOMTranslator.test.ts +++ b/src/__tests__/LazyDOMTranslator.test.ts @@ -90,7 +90,7 @@ test('Does not translate elements if they are not attached to the DOM or not vis // Attach to the DOM; elements with display = 'none' should not be intersectable document.body.appendChild(div); - // Hidden: Element with the visible property is considered intersectable, so use the display property instead. + // Hidden: Element with the visible property is considered intersectable, so use the display property instead div.style.display = 'none'; lazyTranslator.attach(div); @@ -98,6 +98,13 @@ test('Does not translate elements if they are not attached to the DOM or not vis expect(translator.mock.calls).toHaveLength(0); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // the element becomes visible and is translated + div.style.display = 'block'; + await awaitTranslation(); + + expect(translator.mock.calls).toHaveLength(1); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); test('Not translate element after detach', async () => { From 39bb67b79da133f0924f203442efc07f1a30064f Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 27 Apr 2025 22:23:24 +0200 Subject: [PATCH 081/313] test: fix typo, improve style --- src/__tests__/LazyDOMTranslator.test.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/__tests__/LazyDOMTranslator.test.ts b/src/__tests__/LazyDOMTranslator.test.ts index 87c81ff..354bd56 100644 --- a/src/__tests__/LazyDOMTranslator.test.ts +++ b/src/__tests__/LazyDOMTranslator.test.ts @@ -109,7 +109,7 @@ test('Does not translate elements if they are not attached to the DOM or not vis test('Not translate element after detach', async () => { const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); - // element not attach to DOM + const div = document.createElement('div'); div.innerHTML = 'Hello world!'; div.style.display = 'none'; @@ -117,14 +117,16 @@ test('Not translate element after detach', async () => { lazyTranslator.attach(div); await awaitTranslation(); + // not translate because element not visible + expect(translator.mock.calls).toHaveLength(0); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - // after the element becomes visible, it still isn't translated + // element is detached, he becomes visible but still isn't translated after detaching lazyTranslator.detach(div); - // visible element div.style.display = 'block'; + expect(translator.mock.calls).toHaveLength(0); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); @@ -167,7 +169,7 @@ test('Translate element only after it appears in the viewport', async () => { await awaitTranslation(); // don't translate because the element doesn't intersect the container - expect(translator).not.toHaveBeenCalled(); + expect(translator.mock.calls).toHaveLength(0); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); // change coordinates, now element in viewport @@ -185,7 +187,7 @@ test('Translate element only after it appears in the viewport', async () => { document.dispatchEvent(new Event('scroll', { bubbles: true })); await awaitTranslation(); - expect(translator).toHaveBeenCalledTimes(1); + expect(translator.mock.calls).toHaveLength(1); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); @@ -228,10 +230,10 @@ test('Not translate the element if it is still not in the viewport after scrolli await awaitTranslation(); // don't translate because the element doesn't intersect the container - expect(translator).not.toHaveBeenCalled(); + expect(translator.mock.calls).toHaveLength(0); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - // change coordinates, now element in viewport + // change coordinates, element still not in viewport mockBoundingClientRect(div, { top: 330, left: 0, @@ -247,6 +249,6 @@ test('Not translate the element if it is still not in the viewport after scrolli await awaitTranslation(); // still have not translate - expect(translator).toHaveBeenCalledTimes(0); + expect(translator.mock.calls).toHaveLength(0); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); From fded9c80d781c6af853a5edaf3ab21ac51690590 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 27 Apr 2025 22:53:45 +0200 Subject: [PATCH 082/313] test: delete unnecessary code --- src/__tests__/LazyDOMTranslator.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/__tests__/LazyDOMTranslator.test.ts b/src/__tests__/LazyDOMTranslator.test.ts index 354bd56..4b4d87e 100644 --- a/src/__tests__/LazyDOMTranslator.test.ts +++ b/src/__tests__/LazyDOMTranslator.test.ts @@ -24,7 +24,6 @@ const mockBoundingClientRect = (element: HTMLElement, rect: Partial) => x: 0, y: 0, ...rect, - toJSON: () => JSON.stringify(rect), })), }); }; @@ -137,9 +136,6 @@ test('Translate element only after it appears in the viewport', async () => { container.appendChild(div); document.body.appendChild(container); - container.style.width = '300px'; - container.style.height = '300px'; - mockBoundingClientRect(container, { top: 0, left: 0, @@ -198,9 +194,6 @@ test('Not translate the element if it is still not in the viewport after scrolli container.appendChild(div); document.body.appendChild(container); - container.style.width = '300px'; - container.style.height = '300px'; - mockBoundingClientRect(container, { top: 0, left: 0, From b02c419889a9801f273ac8f6fec2801a0b1943d5 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 28 Apr 2025 01:29:24 +0200 Subject: [PATCH 083/313] test: use default setting --- src/__tests__/LazyDOMTranslator.test.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/__tests__/LazyDOMTranslator.test.ts b/src/__tests__/LazyDOMTranslator.test.ts index 4b4d87e..d87526f 100644 --- a/src/__tests__/LazyDOMTranslator.test.ts +++ b/src/__tests__/LazyDOMTranslator.test.ts @@ -130,13 +130,11 @@ test('Not translate element after detach', async () => { }); test('Translate element only after it appears in the viewport', async () => { - const container = document.createElement('div'); const div = document.createElement('div'); div.innerHTML = 'Hello world!'; - container.appendChild(div); - document.body.appendChild(container); + document.body.appendChild(div); - mockBoundingClientRect(container, { + mockBoundingClientRect(document.body, { top: 0, left: 0, bottom: 300, @@ -158,7 +156,6 @@ test('Translate element only after it appears in the viewport', async () => { const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator, - config: { intersectionConfig: { root: container } }, }); lazyTranslator.attach(div); @@ -170,7 +167,7 @@ test('Translate element only after it appears in the viewport', async () => { // change coordinates, now element in viewport mockBoundingClientRect(div, { - top: 100, + top: 0, left: 0, bottom: 200, right: 100, @@ -188,13 +185,11 @@ test('Translate element only after it appears in the viewport', async () => { }); test('Not translate the element if it is still not in the viewport after scrolling', async () => { - const container = document.createElement('div'); const div = document.createElement('div'); div.innerHTML = 'Hello world!'; - container.appendChild(div); - document.body.appendChild(container); + document.body.appendChild(div); - mockBoundingClientRect(container, { + mockBoundingClientRect(document.body, { top: 0, left: 0, bottom: 300, @@ -216,7 +211,6 @@ test('Not translate the element if it is still not in the viewport after scrolli const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator, - config: { intersectionConfig: { root: container } }, }); lazyTranslator.attach(div); From 155e3e1701911a23f311de4116523f5a091e4159 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 28 Apr 2025 01:43:21 +0200 Subject: [PATCH 084/313] test: improve discretion --- src/__tests__/LazyDOMTranslator.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/__tests__/LazyDOMTranslator.test.ts b/src/__tests__/LazyDOMTranslator.test.ts index d87526f..ee6ac32 100644 --- a/src/__tests__/LazyDOMTranslator.test.ts +++ b/src/__tests__/LazyDOMTranslator.test.ts @@ -87,12 +87,11 @@ test('Does not translate elements if they are not attached to the DOM or not vis expect(translator.mock.calls).toHaveLength(0); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - // Attach to the DOM; elements with display = 'none' should not be intersectable + // Attach to the DOM, but elements with display = 'none' is not be intersectable, and not translate + // Element with the visible property is considered intersectable, so use the display=none property instead document.body.appendChild(div); - // Hidden: Element with the visible property is considered intersectable, so use the display property instead div.style.display = 'none'; - lazyTranslator.attach(div); await awaitTranslation(); expect(translator.mock.calls).toHaveLength(0); @@ -109,6 +108,7 @@ test('Does not translate elements if they are not attached to the DOM or not vis test('Not translate element after detach', async () => { const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); + // create element with display=none, it not intersectible const div = document.createElement('div'); div.innerHTML = 'Hello world!'; div.style.display = 'none'; @@ -121,7 +121,8 @@ test('Not translate element after detach', async () => { expect(translator.mock.calls).toHaveLength(0); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - // element is detached, he becomes visible but still isn't translated after detaching + // element is detached to DOM + // becomes visible and intersectable, but is still not translated after detach lazyTranslator.detach(div); div.style.display = 'block'; @@ -175,11 +176,11 @@ test('Translate element only after it appears in the viewport', async () => { height: 100, }); - // simulates the scroll event, and the polyfill listens for the "scroll" event in the document + // simulates the scroll event; the polyfill listens for the "scroll" event in the document // the scroll event triggers an intersection check document.dispatchEvent(new Event('scroll', { bubbles: true })); - await awaitTranslation(); + expect(translator.mock.calls).toHaveLength(1); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); From 24b9949d6d5eda3a236d58ea78c9c36a87e3f146 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 28 Apr 2025 03:13:07 +0200 Subject: [PATCH 085/313] test: simplify test case --- src/__tests__/DOMTranslator.test.ts | 35 +++++++++++++---------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/src/__tests__/DOMTranslator.test.ts b/src/__tests__/DOMTranslator.test.ts index d977b18..eed90df 100644 --- a/src/__tests__/DOMTranslator.test.ts +++ b/src/__tests__/DOMTranslator.test.ts @@ -75,38 +75,31 @@ test('Checks existing element in storage', async () => { expect(domTranslator.hasNode(div.childNodes[0])).toBe(false); }); -test('Update translation for element ', async () => { +test('Update translation for element', async () => { const domTranslator = new DOMTranslator({ isTranslatableNode: Boolean, translateCallback: translator, }); - const div = document.createElement('div'); - const originalText = 'Hello world!'; - div.innerHTML = originalText; - // spy on the updateNode method const updateNodesSpy = vi.spyOn(domTranslator as DOMTranslator, 'updateNode'); - // translate element - domTranslator.translateNode(div.childNodes[0]); - await awaitTranslation(); + const text = 'Hello world!'; + const textNode = document.createTextNode(text); - // update element - const newText = 'Goodbye world!'; - div.innerHTML = newText; - domTranslator.translateNode(div.childNodes[0]); + // translate element + domTranslator.translateNode(textNode); await awaitTranslation(); - domTranslator.updateNode(div.childNodes[0]); + // In the actual code the sequence of calls is as follows: + // Node is translated -> the node's content is changed -> + // Node update event is triggered -> the updateNode method is called with new translated content + domTranslator.updateNode(textNode); await awaitTranslation(); - // correct text translation - expect(div.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(div.innerHTML).toMatch(newText); - // update calls one time expect(updateNodesSpy).toBeCalledTimes(1); - expect(updateNodesSpy.mock.calls[0][0]).toMatchObject( + expect(updateNodesSpy.mock.calls[0][0].nodeValue).toMatch(text); + expect(updateNodesSpy.mock.calls[0][0].nodeValue).toMatch( containsRegex(TRANSLATION_SYMBOL), ); }); @@ -137,16 +130,18 @@ describe('Restore node', () => { }); const div = document.createElement('div'); div.innerHTML = 'Hello world!'; + + // translate domTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); - // update text + // translate again const newText = 'Hello world 1234!'; div.innerHTML = newText; domTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); - // restore + // restore, elements have the last updated text and have not translated domTranslator.restoreNode(div.childNodes[0]); expect(div.innerHTML).toMatch(newText); expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); From 1db542cf57eebea189faa917de334d7d421d3abe Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 28 Apr 2025 03:18:42 +0200 Subject: [PATCH 086/313] test: remove unnecessary block --- src/__tests__/DOMTranslator.test.ts | 160 ++++++++++++++-------------- 1 file changed, 81 insertions(+), 79 deletions(-) diff --git a/src/__tests__/DOMTranslator.test.ts b/src/__tests__/DOMTranslator.test.ts index eed90df..ae3ca92 100644 --- a/src/__tests__/DOMTranslator.test.ts +++ b/src/__tests__/DOMTranslator.test.ts @@ -1,6 +1,28 @@ import { DOMTranslator } from '../DOMTranslator'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; +// mock for to translate the entire element tree +const handleElementTree = (node: Node, callback: (node: Node) => void) => { + if (node instanceof Element) { + vi.fn((root: Element, callback: (n: Node) => void) => { + const handel = (n: Node) => { + callback(n); + if (n instanceof Element) { + Array.from(n.childNodes).forEach(handel); + Array.from(n.attributes).forEach(callback); + } + }; + handel(root); + })(node, (node) => { + callback(node); + }); + } +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + test('Translate and restore original element text', async () => { const domTranslator = new DOMTranslator({ isTranslatableNode: Boolean, @@ -104,91 +126,71 @@ test('Update translation for element', async () => { ); }); -describe('Restore node', () => { - // mock for to translate the entire element tree - const handleElementTree = (node: Node, callback: (node: Node) => void) => { - if (node instanceof Element) { - vi.fn((root: Element, callback: (n: Node) => void) => { - const handel = (n: Node) => { - callback(n); - if (n instanceof Element) { - Array.from(n.childNodes).forEach(handel); - Array.from(n.attributes).forEach(callback); - } - }; - handel(root); - })(node, (node) => { - callback(node); - }); - } - }; - - test('Restore the text element after a few translations', async () => { - const domTranslator = new DOMTranslator({ - isTranslatableNode: Boolean, - translateCallback: translator, - }); - const div = document.createElement('div'); - div.innerHTML = 'Hello world!'; - - // translate - domTranslator.translateNode(div.childNodes[0]); - await awaitTranslation(); - - // translate again - const newText = 'Hello world 1234!'; - div.innerHTML = newText; - domTranslator.translateNode(div.childNodes[0]); - await awaitTranslation(); - - // restore, elements have the last updated text and have not translated - domTranslator.restoreNode(div.childNodes[0]); - expect(div.innerHTML).toMatch(newText); - expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); +test('Restore the text element after a few translations', async () => { + const domTranslator = new DOMTranslator({ + isTranslatableNode: Boolean, + translateCallback: translator, }); + const div = document.createElement('div'); + div.innerHTML = 'Hello world!'; - test('Restore translations from all nested nodes in the element', async () => { - const domTranslator = new DOMTranslator({ - isTranslatableNode: Boolean, - translateCallback: translator, - }); - const parentDiv = document.createElement('div'); - parentDiv.innerHTML = 'Hello world!'; - const childDiv = document.createElement('div'); - childDiv.innerHTML = 'Hello world too!'; - parentDiv.append(childDiv); + // translate + domTranslator.translateNode(div.childNodes[0]); + await awaitTranslation(); - handleElementTree(parentDiv, domTranslator.translateNode); - await awaitTranslation(); + // translate again + const newText = 'Hello world 1234!'; + div.innerHTML = newText; + domTranslator.translateNode(div.childNodes[0]); + await awaitTranslation(); - domTranslator.restoreNode(parentDiv); + // restore, elements have the last updated text and have not translated + domTranslator.restoreNode(div.childNodes[0]); + expect(div.innerHTML).toMatch(newText); + expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); +}); - // child node and target has not translated text - expect(parentDiv.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(childDiv.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); +test('Restore translations from all nested nodes in the element', async () => { + const domTranslator = new DOMTranslator({ + isTranslatableNode: Boolean, + translateCallback: translator, }); + const parentDiv = document.createElement('div'); + parentDiv.innerHTML = 'Hello world!'; + const childDiv = document.createElement('div'); + childDiv.innerHTML = 'Hello world too!'; + parentDiv.append(childDiv); - test('Delete translation only from target element', async () => { - const domTranslator = new DOMTranslator({ - isTranslatableNode: Boolean, - translateCallback: translator, - }); - const parentDiv = document.createElement('div'); - parentDiv.innerHTML = 'Hello world!'; - const childDiv = document.createElement('div'); - childDiv.innerHTML = 'Hello world too!'; - parentDiv.append(childDiv); - - handleElementTree(parentDiv, domTranslator.translateNode); - await awaitTranslation(); - - domTranslator.restoreNode(parentDiv.childNodes[0], true); - - //target element has not translation - expect(parentDiv.childNodes[0].textContent).not.toMatch( - containsRegex(TRANSLATION_SYMBOL), - ); - // child element still has translation - expect(childDiv.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); + handleElementTree(parentDiv, domTranslator.translateNode); + await awaitTranslation(); + + domTranslator.restoreNode(parentDiv); + + // child node and target has not translated text + expect(parentDiv.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(childDiv.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); +}); + +test('Delete translation only from target element', async () => { + const domTranslator = new DOMTranslator({ + isTranslatableNode: Boolean, + translateCallback: translator, }); + const parentDiv = document.createElement('div'); + parentDiv.innerHTML = 'Hello world!'; + const childDiv = document.createElement('div'); + childDiv.innerHTML = 'Hello world too!'; + parentDiv.append(childDiv); + + handleElementTree(parentDiv, domTranslator.translateNode); + await awaitTranslation(); + + domTranslator.restoreNode(parentDiv.childNodes[0], true); + + //target element has not translation + expect(parentDiv.childNodes[0].textContent).not.toMatch( + containsRegex(TRANSLATION_SYMBOL), + ); + // child element still has translation + expect(childDiv.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); From 89f5e887c5bcbbf35e7b9953a6ad0ca96a0f2ca9 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 28 Apr 2025 23:00:30 +0200 Subject: [PATCH 087/313] chore: rename class and class members --- src/DefaultNodesTranslator.ts | 8 +++--- src/LazyDOMTranslator.ts | 22 ++++++++-------- src/TranslationDispatcher.ts | 6 ++--- src/__tests__/LazyDOMTranslator.test.ts | 34 +++++++++++++++++-------- src/__tests__/NodesTranslator.test.ts | 8 +++--- 5 files changed, 45 insertions(+), 33 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 5d81d82..205bd82 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -1,7 +1,7 @@ import { NodesTranslator } from './NodesTranslator'; import { Config, TranslatorInterface } from './types'; import { configureTranslatableNodePredicate } from './utils/nodes'; -import { DOMTranslator, LazyDOMTranslator, TranslationDispatcher } from '.'; +import { DOMTranslator, IntersectionObserverWithFilter, TranslationDispatcher } from '.'; /** * Module for dynamic translate a DOM nodes. @@ -22,9 +22,9 @@ export class DefaultNodesTranslator extends NodesTranslator { translateCallback, }); - const lazyDOMTranslator = new LazyDOMTranslator({ - isTranslatableNode: innerConfig.isTranslatableNode, - translator: domTranslator.translateNode, + const lazyDOMTranslator = new IntersectionObserverWithFilter({ + filter: innerConfig.isTranslatableNode, + onIntersected: domTranslator.translateNode, }); super({ diff --git a/src/LazyDOMTranslator.ts b/src/LazyDOMTranslator.ts index 7d38cd5..52725ff 100644 --- a/src/LazyDOMTranslator.ts +++ b/src/LazyDOMTranslator.ts @@ -4,27 +4,27 @@ import { TranslatableNodePredicate } from './types'; * Translates nodes only if they intersect the viewport */ -export class LazyDOMTranslator { +export class IntersectionObserverWithFilter { // Store the nodes that is under observing for intersection private readonly nodesObservedForIntersection = new WeakSet(); private readonly intersectionObserver: IntersectionObserver; - private readonly isTranslatableNode; - private readonly translator; + private readonly filter; + private readonly onIntersected; constructor({ - isTranslatableNode, - translator, + filter, + onIntersected, config, }: { - isTranslatableNode: TranslatableNodePredicate; - translator: (node: Node) => void; + filter: TranslatableNodePredicate; + onIntersected: (node: Node) => void; config?: { intersectionConfig?: IntersectionObserverInit; }; }) { - this.isTranslatableNode = isTranslatableNode; - this.translator = translator; + this.filter = filter; + this.onIntersected = onIntersected; this.intersectionObserver = new IntersectionObserver((entries, observer) => { entries.forEach((entry) => { @@ -60,10 +60,10 @@ export class LazyDOMTranslator { // Translate child text nodes and attributes of target node // WARNING: we shall not touch inner nodes, because its may still not intersected node.childNodes.forEach((node) => { - if (node instanceof Element || !this.isTranslatableNode(node)) { + if (node instanceof Element || !this.filter(node)) { return; } - this.translator(node); + this.onIntersected(node); }); } } diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index a7fc487..031ce6a 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -1,5 +1,5 @@ import { DOMTranslator } from './DOMTranslator'; -import { LazyDOMTranslator } from './LazyDOMTranslator'; +import { IntersectionObserverWithFilter } from './LazyDOMTranslator'; import { TranslatableNodePredicate } from './types'; import { isIntersectableNode } from './utils/isIntersectableNode'; import { visitWholeTree } from './utils/visitWholeTree'; @@ -10,7 +10,7 @@ import { visitWholeTree } from './utils/visitWholeTree'; export class TranslationDispatcher { private readonly config; private readonly domTranslator: DOMTranslator; - private readonly lazyDOMTranslator: LazyDOMTranslator; + private readonly lazyDOMTranslator: IntersectionObserverWithFilter; constructor({ config, @@ -22,7 +22,7 @@ export class TranslationDispatcher { lazyTranslate: boolean; }; domTranslator: DOMTranslator; - lazyDOMTranslator: LazyDOMTranslator; + lazyDOMTranslator: IntersectionObserverWithFilter; }) { this.config = config; this.domTranslator = domTranslator; diff --git a/src/__tests__/LazyDOMTranslator.test.ts b/src/__tests__/LazyDOMTranslator.test.ts index ee6ac32..f2dfb36 100644 --- a/src/__tests__/LazyDOMTranslator.test.ts +++ b/src/__tests__/LazyDOMTranslator.test.ts @@ -1,4 +1,4 @@ -import { LazyDOMTranslator } from '../LazyDOMTranslator'; +import { IntersectionObserverWithFilter } from '../LazyDOMTranslator'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL } from './utils'; require('intersection-observer'); @@ -38,7 +38,10 @@ test('Translate element from viewport', async () => { div.innerHTML = 'Hello, World!'; document.body.appendChild(div); - const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); + const lazyTranslator = new IntersectionObserverWithFilter({ + filter: isTranslatableNode, + onIntersected: translator, + }); lazyTranslator.attach(div); await awaitTranslation(); @@ -54,7 +57,10 @@ test('Translate one element twice', async () => { div.innerHTML = 'Hello, World!'; document.body.appendChild(div); - const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); + const lazyTranslator = new IntersectionObserverWithFilter({ + filter: isTranslatableNode, + onIntersected: translator, + }); lazyTranslator.attach(div); await awaitTranslation(); @@ -75,7 +81,10 @@ test('Translate one element twice', async () => { }); test('Does not translate elements if they are not attached to the DOM or not visible', async () => { - const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); + const lazyTranslator = new IntersectionObserverWithFilter({ + filter: isTranslatableNode, + onIntersected: translator, + }); // element not attach to DOM const div = document.createElement('div'); @@ -106,7 +115,10 @@ test('Does not translate elements if they are not attached to the DOM or not vis }); test('Not translate element after detach', async () => { - const lazyTranslator = new LazyDOMTranslator({ isTranslatableNode, translator }); + const lazyTranslator = new IntersectionObserverWithFilter({ + filter: isTranslatableNode, + onIntersected: translator, + }); // create element with display=none, it not intersectible const div = document.createElement('div'); @@ -154,9 +166,9 @@ test('Translate element only after it appears in the viewport', async () => { height: 100, }); - const lazyTranslator = new LazyDOMTranslator({ - isTranslatableNode, - translator, + const lazyTranslator = new IntersectionObserverWithFilter({ + filter: isTranslatableNode, + onIntersected: translator, }); lazyTranslator.attach(div); @@ -209,9 +221,9 @@ test('Not translate the element if it is still not in the viewport after scrolli height: 100, }); - const lazyTranslator = new LazyDOMTranslator({ - isTranslatableNode, - translator, + const lazyTranslator = new IntersectionObserverWithFilter({ + filter: isTranslatableNode, + onIntersected: translator, }); lazyTranslator.attach(div); diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index ae8b2b3..827d990 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'fs'; import { DOMTranslator } from '../DOMTranslator'; -import { LazyDOMTranslator } from '../LazyDOMTranslator'; +import { IntersectionObserverWithFilter } from '../LazyDOMTranslator'; import { NodesTranslator } from '../NodesTranslator'; import { TranslationDispatcher } from '../TranslationDispatcher'; import { Config, TranslatorInterface } from '../types'; @@ -45,9 +45,9 @@ function buildClass(translateCallback: TranslatorInterface, config?: Config) { translateCallback, }); - const lazyDOMTranslator = new LazyDOMTranslator({ - isTranslatableNode: innerConfig.isTranslatableNode, - translator: domTranslator.translateNode, + const lazyDOMTranslator = new IntersectionObserverWithFilter({ + filter: innerConfig.isTranslatableNode, + onIntersected: domTranslator.translateNode, }); return { From 5ab2b941b40096818afd671eb7b982b993a4c865 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 28 Apr 2025 23:01:43 +0200 Subject: [PATCH 088/313] chore: rename files --- src/{LazyDOMTranslator.ts => IntersectionObserverWithFilter.ts} | 0 src/TranslationDispatcher.ts | 2 +- ...ranslator.test.ts => IntersectionObserverWithFilter.test.ts} | 2 +- src/__tests__/NodesTranslator.test.ts | 2 +- src/index.ts | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename src/{LazyDOMTranslator.ts => IntersectionObserverWithFilter.ts} (100%) rename src/__tests__/{LazyDOMTranslator.test.ts => IntersectionObserverWithFilter.test.ts} (98%) diff --git a/src/LazyDOMTranslator.ts b/src/IntersectionObserverWithFilter.ts similarity index 100% rename from src/LazyDOMTranslator.ts rename to src/IntersectionObserverWithFilter.ts diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 031ce6a..ebecea3 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -1,5 +1,5 @@ import { DOMTranslator } from './DOMTranslator'; -import { IntersectionObserverWithFilter } from './LazyDOMTranslator'; +import { IntersectionObserverWithFilter } from './IntersectionObserverWithFilter'; import { TranslatableNodePredicate } from './types'; import { isIntersectableNode } from './utils/isIntersectableNode'; import { visitWholeTree } from './utils/visitWholeTree'; diff --git a/src/__tests__/LazyDOMTranslator.test.ts b/src/__tests__/IntersectionObserverWithFilter.test.ts similarity index 98% rename from src/__tests__/LazyDOMTranslator.test.ts rename to src/__tests__/IntersectionObserverWithFilter.test.ts index f2dfb36..579c904 100644 --- a/src/__tests__/LazyDOMTranslator.test.ts +++ b/src/__tests__/IntersectionObserverWithFilter.test.ts @@ -1,4 +1,4 @@ -import { IntersectionObserverWithFilter } from '../LazyDOMTranslator'; +import { IntersectionObserverWithFilter } from '../IntersectionObserverWithFilter'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL } from './utils'; require('intersection-observer'); diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 827d990..d1b9043 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'fs'; import { DOMTranslator } from '../DOMTranslator'; -import { IntersectionObserverWithFilter } from '../LazyDOMTranslator'; +import { IntersectionObserverWithFilter } from '../IntersectionObserverWithFilter'; import { NodesTranslator } from '../NodesTranslator'; import { TranslationDispatcher } from '../TranslationDispatcher'; import { Config, TranslatorInterface } from '../types'; diff --git a/src/index.ts b/src/index.ts index 6702e1c..9b5627c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export * from './NodesTranslator'; export * from './TranslationDispatcher'; export * from './DOMTranslator'; -export * from './LazyDOMTranslator'; +export * from './IntersectionObserverWithFilter'; export * from './DefaultNodesTranslator'; From 64020b69aad54c5d82be2e62ebfc95e6f49b23a9 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 28 Apr 2025 23:19:18 +0200 Subject: [PATCH 089/313] test: update mock --- .../IntersectionObserverWithFilter.test.ts | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/__tests__/IntersectionObserverWithFilter.test.ts b/src/__tests__/IntersectionObserverWithFilter.test.ts index 579c904..9ba2cc6 100644 --- a/src/__tests__/IntersectionObserverWithFilter.test.ts +++ b/src/__tests__/IntersectionObserverWithFilter.test.ts @@ -7,28 +7,31 @@ const translator = vi.fn().mockImplementation(async (node: Node) => { node.textContent += TRANSLATION_SYMBOL; }); -const isTranslatableNode = (node: Node) => node instanceof Text || node instanceof Attr; +const isTranslatableNode = () => true; // jsdom does not actually modify element coordinates // Create a mock that sets the real values for the coordinates -const mockBoundingClientRect = (element: HTMLElement, rect: Partial) => { +// DOMRect interface requires the toJSON property, this is not necessary for our tests, so use Omit utility type +const mockBoundingClientRect = (element: HTMLElement, rect: Omit) => { Object.defineProperty(element, 'getBoundingClientRect', { configurable: true, - value: vi.fn(() => ({ - top: 0, - left: 0, - bottom: 0, - right: 0, - width: 0, - height: 0, - x: 0, - y: 0, + value: () => ({ ...rect, - })), + }), }); }; beforeEach(() => { + mockBoundingClientRect(document.body, { + top: 0, + left: 0, + bottom: 0, + right: 0, + width: 0, + height: 0, + x: 0, + y: 0, + }); document.body.innerHTML = ''; vi.clearAllMocks(); }); @@ -154,6 +157,8 @@ test('Translate element only after it appears in the viewport', async () => { right: 300, width: 300, height: 300, + x: 0, + y: 0, }); // element out of viewport, it not intersect container @@ -164,6 +169,8 @@ test('Translate element only after it appears in the viewport', async () => { right: 100, width: 100, height: 100, + x: 0, + y: 0, }); const lazyTranslator = new IntersectionObserverWithFilter({ @@ -186,6 +193,8 @@ test('Translate element only after it appears in the viewport', async () => { right: 100, width: 100, height: 100, + x: 0, + y: 0, }); // simulates the scroll event; the polyfill listens for the "scroll" event in the document @@ -209,6 +218,8 @@ test('Not translate the element if it is still not in the viewport after scrolli right: 300, width: 300, height: 300, + x: 0, + y: 0, }); // element out of viewport, it not intersect container @@ -219,6 +230,8 @@ test('Not translate the element if it is still not in the viewport after scrolli right: 100, width: 100, height: 100, + x: 0, + y: 0, }); const lazyTranslator = new IntersectionObserverWithFilter({ @@ -241,6 +254,8 @@ test('Not translate the element if it is still not in the viewport after scrolli right: 100, width: 100, height: 100, + x: 0, + y: 0, }); // simulates the scroll event, and the polyfill listens for the "scroll" event in the document From 0a91875913579945ead89aaa47fbc3fed7f88c51 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 29 Apr 2025 00:09:13 +0200 Subject: [PATCH 090/313] test: improve style, test name --- .../IntersectionObserverWithFilter.test.ts | 74 +++++++++---------- 1 file changed, 35 insertions(+), 39 deletions(-) diff --git a/src/__tests__/IntersectionObserverWithFilter.test.ts b/src/__tests__/IntersectionObserverWithFilter.test.ts index 9ba2cc6..7d2439e 100644 --- a/src/__tests__/IntersectionObserverWithFilter.test.ts +++ b/src/__tests__/IntersectionObserverWithFilter.test.ts @@ -36,7 +36,7 @@ beforeEach(() => { vi.clearAllMocks(); }); -test('Translate element from viewport', async () => { +test('Call onIntersected for node from viewport', async () => { const div = document.createElement('div'); div.innerHTML = 'Hello, World!'; document.body.appendChild(div); @@ -49,9 +49,8 @@ test('Translate element from viewport', async () => { lazyTranslator.attach(div); await awaitTranslation(); - // The mock function was called ones - expect(translator.mock.calls).toHaveLength(1); - expect(translator).toHaveBeenCalledWith(div.childNodes[0]); + // The mock function was called once + expect(translator.mock.calls).toEqual([[div.childNodes[0]]]); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); @@ -83,47 +82,46 @@ test('Translate one element twice', async () => { expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Does not translate elements if they are not attached to the DOM or not visible', async () => { +test('Call onIntersected for a node only when it becomes intersectable', async () => { const lazyTranslator = new IntersectionObserverWithFilter({ filter: isTranslatableNode, onIntersected: translator, }); - // element not attach to DOM + // node not attach to DOM, it not intersectable, not translate it const div = document.createElement('div'); div.innerHTML = 'Hello, world'; lazyTranslator.attach(div); await awaitTranslation(); - expect(translator.mock.calls).toHaveLength(0); + expect(translator.mock.calls).toEqual([]); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); // Attach to the DOM, but elements with display = 'none' is not be intersectable, and not translate - // Element with the visible property is considered intersectable, so use the display=none property instead + // node with the visible='hidden' property is considered intersectable, so use the display=none property instead document.body.appendChild(div); div.style.display = 'none'; - await awaitTranslation(); - expect(translator.mock.calls).toHaveLength(0); + expect(translator.mock.calls).toEqual([]); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - // the element becomes visible and is translated + // the node becomes visible and is translated div.style.display = 'block'; await awaitTranslation(); - expect(translator.mock.calls).toHaveLength(1); + expect(translator.mock.calls).toEqual([[div.childNodes[0]]]); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Not translate element after detach', async () => { +test('Not call onIntersected after node is detached', async () => { const lazyTranslator = new IntersectionObserverWithFilter({ filter: isTranslatableNode, onIntersected: translator, }); - // create element with display=none, it not intersectible + // create node with display=none, it not intersectible const div = document.createElement('div'); div.innerHTML = 'Hello world!'; div.style.display = 'none'; @@ -132,20 +130,24 @@ test('Not translate element after detach', async () => { lazyTranslator.attach(div); await awaitTranslation(); - // not translate because element not visible - expect(translator.mock.calls).toHaveLength(0); + // not translate because node not visible + expect(translator.mock.calls).toEqual([]); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - // element is detached to DOM - // becomes visible and intersectable, but is still not translated after detach + // node is detached lazyTranslator.detach(div); + // becomes visible and intersectable, but is still not translated after detach div.style.display = 'block'; - expect(translator.mock.calls).toHaveLength(0); + expect(translator.mock.calls).toEqual([]); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Translate element only after it appears in the viewport', async () => { +test('Call onIntersected only after node intersect viewport', async () => { + const lazyTranslator = new IntersectionObserverWithFilter({ + filter: isTranslatableNode, + onIntersected: translator, + }); const div = document.createElement('div'); div.innerHTML = 'Hello world!'; document.body.appendChild(div); @@ -173,19 +175,14 @@ test('Translate element only after it appears in the viewport', async () => { y: 0, }); - const lazyTranslator = new IntersectionObserverWithFilter({ - filter: isTranslatableNode, - onIntersected: translator, - }); - lazyTranslator.attach(div); await awaitTranslation(); - // don't translate because the element doesn't intersect the container - expect(translator.mock.calls).toHaveLength(0); + // don't translate because the node doesn't intersect the container + expect(translator.mock.calls).toEqual([]); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - // change coordinates, now element in viewport + // change coordinates, now node in viewport mockBoundingClientRect(div, { top: 0, left: 0, @@ -202,11 +199,15 @@ test('Translate element only after it appears in the viewport', async () => { document.dispatchEvent(new Event('scroll', { bubbles: true })); await awaitTranslation(); - expect(translator.mock.calls).toHaveLength(1); + expect(translator.mock.calls).toEqual([[div.childNodes[0]]]); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Not translate the element if it is still not in the viewport after scrolling', async () => { +test('Not call a onIntersected for node that not intersect viewport after scrolling', async () => { + const lazyTranslator = new IntersectionObserverWithFilter({ + filter: isTranslatableNode, + onIntersected: translator, + }); const div = document.createElement('div'); div.innerHTML = 'Hello world!'; document.body.appendChild(div); @@ -222,7 +223,7 @@ test('Not translate the element if it is still not in the viewport after scrolli y: 0, }); - // element out of viewport, it not intersect container + // node out of viewport, it not intersect container mockBoundingClientRect(div, { top: 400, left: 0, @@ -234,19 +235,14 @@ test('Not translate the element if it is still not in the viewport after scrolli y: 0, }); - const lazyTranslator = new IntersectionObserverWithFilter({ - filter: isTranslatableNode, - onIntersected: translator, - }); - lazyTranslator.attach(div); await awaitTranslation(); // don't translate because the element doesn't intersect the container - expect(translator.mock.calls).toHaveLength(0); + expect(translator.mock.calls).toEqual([]); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - // change coordinates, element still not in viewport + // change coordinates, node still not in viewport mockBoundingClientRect(div, { top: 330, left: 0, @@ -264,6 +260,6 @@ test('Not translate the element if it is still not in the viewport after scrolli await awaitTranslation(); // still have not translate - expect(translator.mock.calls).toHaveLength(0); + expect(translator.mock.calls).toEqual([]); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); From 55b5908d8d116babddfc4459db4facb856b79765 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 29 Apr 2025 00:45:03 +0200 Subject: [PATCH 091/313] test: delete test case --- .../IntersectionObserverWithFilter.test.ts | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/src/__tests__/IntersectionObserverWithFilter.test.ts b/src/__tests__/IntersectionObserverWithFilter.test.ts index 7d2439e..2a4b3ab 100644 --- a/src/__tests__/IntersectionObserverWithFilter.test.ts +++ b/src/__tests__/IntersectionObserverWithFilter.test.ts @@ -54,34 +54,6 @@ test('Call onIntersected for node from viewport', async () => { expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Translate one element twice', async () => { - const div = document.createElement('div'); - div.innerHTML = 'Hello, World!'; - document.body.appendChild(div); - - const lazyTranslator = new IntersectionObserverWithFilter({ - filter: isTranslatableNode, - onIntersected: translator, - }); - lazyTranslator.attach(div); - await awaitTranslation(); - - expect(translator.mock.calls).toHaveLength(1); - expect(translator).toHaveBeenCalledWith(div.childNodes[0]); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - - // update element content - const updatedText = 'Hello, World 12345!'; - div.innerHTML = updatedText; - - lazyTranslator.attach(div); - await awaitTranslation(); - - // translated text contains translated symbols and updated text - expect(div.textContent).toMatch(updatedText); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); -}); - test('Call onIntersected for a node only when it becomes intersectable', async () => { const lazyTranslator = new IntersectionObserverWithFilter({ filter: isTranslatableNode, From 4fd5d011edea905df9ff80ea1cc5f7c3cfc5c262 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 29 Apr 2025 00:57:34 +0200 Subject: [PATCH 092/313] test: improve test name --- src/__tests__/DOMTranslator.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/__tests__/DOMTranslator.test.ts b/src/__tests__/DOMTranslator.test.ts index ae3ca92..b068a91 100644 --- a/src/__tests__/DOMTranslator.test.ts +++ b/src/__tests__/DOMTranslator.test.ts @@ -23,7 +23,7 @@ beforeEach(() => { vi.clearAllMocks(); }); -test('Translate and restore original element text', async () => { +test('Translate and restore original node text', async () => { const domTranslator = new DOMTranslator({ isTranslatableNode: Boolean, translateCallback: translator, @@ -79,7 +79,7 @@ test('Not translate empty element', async () => { expect(div.childNodes[0].textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Checks existing element in storage', async () => { +test('Translated node has in the storage', async () => { const domTranslator = new DOMTranslator({ isTranslatableNode: Boolean, translateCallback: translator, @@ -97,7 +97,7 @@ test('Checks existing element in storage', async () => { expect(domTranslator.hasNode(div.childNodes[0])).toBe(false); }); -test('Update translation for element', async () => { +test('Update translation for node', async () => { const domTranslator = new DOMTranslator({ isTranslatableNode: Boolean, translateCallback: translator, @@ -126,7 +126,7 @@ test('Update translation for element', async () => { ); }); -test('Restore the text element after a few translations', async () => { +test('Restored node contain the most recent content after few translate', async () => { const domTranslator = new DOMTranslator({ isTranslatableNode: Boolean, translateCallback: translator, From db9fc6da5e1ef427a96f40bc89283efe6a7fe99a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 29 Apr 2025 01:03:26 +0200 Subject: [PATCH 093/313] test: delete test case --- src/__tests__/DOMTranslator.test.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/__tests__/DOMTranslator.test.ts b/src/__tests__/DOMTranslator.test.ts index b068a91..2bb4bd1 100644 --- a/src/__tests__/DOMTranslator.test.ts +++ b/src/__tests__/DOMTranslator.test.ts @@ -63,22 +63,6 @@ test('Get original node text', async () => { ); }); -test('Not translate empty element', async () => { - const domTranslator = new DOMTranslator({ - isTranslatableNode: Boolean, - translateCallback: translator, - }); - - const div = document.createElement('div'); - div.innerHTML = ' '; - - // translate - domTranslator.translateNode(div.childNodes[0]); - await awaitTranslation(); - - expect(div.childNodes[0].textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); -}); - test('Translated node has in the storage', async () => { const domTranslator = new DOMTranslator({ isTranslatableNode: Boolean, From 1b1a37f6495760bcdfa96892f3704bdec08088b8 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 29 Apr 2025 01:07:04 +0200 Subject: [PATCH 094/313] chore: update comment --- src/IntersectionObserverWithFilter.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/IntersectionObserverWithFilter.ts b/src/IntersectionObserverWithFilter.ts index 52725ff..09f7701 100644 --- a/src/IntersectionObserverWithFilter.ts +++ b/src/IntersectionObserverWithFilter.ts @@ -1,9 +1,8 @@ import { TranslatableNodePredicate } from './types'; /** - * Translates nodes only if they intersect the viewport + * Call the provided callback when the node intersects the viewport */ - export class IntersectionObserverWithFilter { // Store the nodes that is under observing for intersection private readonly nodesObservedForIntersection = new WeakSet(); From 52a44bbec4426b24264f7250572967b8ba746051 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 29 Apr 2025 02:21:20 +0200 Subject: [PATCH 095/313] test: add --- src/__tests__/TranslationDispatcher.test.ts | 138 ++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/__tests__/TranslationDispatcher.test.ts diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts new file mode 100644 index 0000000..2020bee --- /dev/null +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -0,0 +1,138 @@ +import { DOMTranslator } from '../DOMTranslator'; +import { IntersectionObserverWithFilter } from '../IntersectionObserverWithFilter'; +import { TranslationDispatcher } from '../TranslationDispatcher'; +import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; + +require('intersection-observer'); + +const lazyTranslatorSpy = vi.spyOn(IntersectionObserverWithFilter.prototype, 'attach'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function createClassDependency( + isTranslatableNode: (node: Node) => boolean, + translateCallback: (text: string) => Promise, +) { + const domTranslator = new DOMTranslator({ + isTranslatableNode: isTranslatableNode, + translateCallback, + }); + const intersectionObserver = new IntersectionObserverWithFilter({ + filter: isTranslatableNode, + onIntersected: domTranslator.translateNode, + }); + return { intersectionObserver, domTranslator }; +} + +test('Translate node immediately', async () => { + const config = { + isTranslatableNode: () => true, + lazyTranslate: true, + }; + const { domTranslator, intersectionObserver } = createClassDependency( + config.isTranslatableNode, + translator, + ); + const translationDispatcher = new TranslationDispatcher({ + config, + domTranslator: domTranslator, + lazyDOMTranslator: intersectionObserver, + }); + + // Node not to attach the DOM, is can`t translate lazy, but should translated immediately + const div = document.createElement('div'); + div.innerHTML = 'Hello, world!'; + translationDispatcher.translateNode(div); + await awaitTranslation(); + + // lazy translator not called + expect(lazyTranslatorSpy).toBeCalledTimes(0); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); +}); + +test('Translate node lazy', async () => { + const config = { + isTranslatableNode: () => true, + lazyTranslate: true, + }; + const { domTranslator, intersectionObserver } = createClassDependency( + config.isTranslatableNode, + translator, + ); + const translationDispatcher = new TranslationDispatcher({ + config, + domTranslator: domTranslator, + lazyDOMTranslator: intersectionObserver, + }); + + const div = document.createElement('div'); + div.innerHTML = 'Hello, world!'; + document.body.appendChild(div); + translationDispatcher.translateNode(div); + await awaitTranslation(); + + // lazy translator called + expect(lazyTranslatorSpy.mock.calls).toEqual([[div]]); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); +}); + +test('Translate immediately with lazyTranslate false', async () => { + const config = { + isTranslatableNode: () => true, + lazyTranslate: false, + }; + const { domTranslator, intersectionObserver } = createClassDependency( + config.isTranslatableNode, + translator, + ); + const translationDispatcher = new TranslationDispatcher({ + config, + domTranslator: domTranslator, + lazyDOMTranslator: intersectionObserver, + }); + + const div = document.createElement('div'); + div.innerHTML = 'Hello, world!'; + document.body.appendChild(div); + translationDispatcher.translateNode(div); + await awaitTranslation(); + + // lazy translator not called + expect(lazyTranslatorSpy.mock.calls).toEqual([]); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); +}); + +test('Translate the entire node tree', async () => { + const config = { + isTranslatableNode: () => true, + lazyTranslate: false, + }; + const { domTranslator, intersectionObserver } = createClassDependency( + config.isTranslatableNode, + translator, + ); + const translationDispatcher = new TranslationDispatcher({ + config, + domTranslator: domTranslator, + lazyDOMTranslator: intersectionObserver, + }); + + const div = document.createElement('div'); + div.innerHTML = 'Hello'; + const div1 = document.createElement('div'); + div1.innerHTML = 'Hello world'; + const p = document.createElement('p'); + p.innerHTML = 'I`m a fox'; + div1.appendChild(p); + div.appendChild(div1); + + translationDispatcher.translateNode(div1); + await awaitTranslation(); + + // all nodes was translated + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div1.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(p.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); +}); From 518a3e8b521c5aecf7914db51fdc9ddcb94220e3 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 29 Apr 2025 02:24:50 +0200 Subject: [PATCH 096/313] test: change check stye --- src/__tests__/DOMTranslator.test.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/__tests__/DOMTranslator.test.ts b/src/__tests__/DOMTranslator.test.ts index 2bb4bd1..0f64df9 100644 --- a/src/__tests__/DOMTranslator.test.ts +++ b/src/__tests__/DOMTranslator.test.ts @@ -90,24 +90,29 @@ test('Update translation for node', async () => { const updateNodesSpy = vi.spyOn(domTranslator as DOMTranslator, 'updateNode'); const text = 'Hello world!'; - const textNode = document.createTextNode(text); + const div = document.createElement('div'); + div.innerHTML = text; // translate element - domTranslator.translateNode(textNode); + domTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); // In the actual code the sequence of calls is as follows: // Node is translated -> the node's content is changed -> // Node update event is triggered -> the updateNode method is called with new translated content - domTranslator.updateNode(textNode); + domTranslator.updateNode(div.childNodes[0]); await awaitTranslation(); // update calls one time expect(updateNodesSpy).toBeCalledTimes(1); - expect(updateNodesSpy.mock.calls[0][0].nodeValue).toMatch(text); - expect(updateNodesSpy.mock.calls[0][0].nodeValue).toMatch( - containsRegex(TRANSLATION_SYMBOL), - ); + expect(updateNodesSpy.mock.calls).toEqual([[div.childNodes[0]]]); + expect(updateNodesSpy.mock.calls).toEqual([ + [ + expect.objectContaining({ + nodeValue: expect.stringMatching(containsRegex(TRANSLATION_SYMBOL)), + }), + ], + ]); }); test('Restored node contain the most recent content after few translate', async () => { From 3ffc28d284a0eaeb98ba08eca789402a13c463c1 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 30 Apr 2025 02:12:13 +0200 Subject: [PATCH 097/313] chore: move --- src/DOMTranslator.ts | 3 ++- src/IntersectionObserverWithFilter.ts | 2 +- src/TranslationDispatcher.ts | 3 ++- src/types.ts | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/DOMTranslator.ts b/src/DOMTranslator.ts index 82d1366..e6cee1b 100644 --- a/src/DOMTranslator.ts +++ b/src/DOMTranslator.ts @@ -1,4 +1,5 @@ -import { TranslatableNodePredicate, TranslatorInterface } from './types'; +import { TranslatableNodePredicate } from './TranslationDispatcher'; +import { TranslatorInterface } from './types'; import { isInViewport } from './utils/isInViewport'; import { visitWholeTree } from './utils/visitWholeTree'; diff --git a/src/IntersectionObserverWithFilter.ts b/src/IntersectionObserverWithFilter.ts index 09f7701..4b1036d 100644 --- a/src/IntersectionObserverWithFilter.ts +++ b/src/IntersectionObserverWithFilter.ts @@ -1,4 +1,4 @@ -import { TranslatableNodePredicate } from './types'; +import { TranslatableNodePredicate } from './TranslationDispatcher'; /** * Call the provided callback when the node intersects the viewport diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index ebecea3..032bc11 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -1,9 +1,10 @@ import { DOMTranslator } from './DOMTranslator'; import { IntersectionObserverWithFilter } from './IntersectionObserverWithFilter'; -import { TranslatableNodePredicate } from './types'; import { isIntersectableNode } from './utils/isIntersectableNode'; import { visitWholeTree } from './utils/visitWholeTree'; +export type TranslatableNodePredicate = (node: Node) => boolean; + /** * Class coordinates the processing of DOM nodes for translation. Choose translation strategy: lazy or immediate. */ diff --git a/src/types.ts b/src/types.ts index 3687d90..ef44915 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ +import { TranslatableNodePredicate } from './TranslationDispatcher'; + export interface Config { isTranslatableNode?: TranslatableNodePredicate; lazyTranslate?: boolean; } export type TranslatorInterface = (text: string, priority: number) => Promise; -export type TranslatableNodePredicate = (node: Node) => boolean; From d56859a523072cc76212476bb9775b1d7aabda08 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 30 Apr 2025 02:13:15 +0200 Subject: [PATCH 098/313] chore: move --- src/DOMTranslator.ts | 3 ++- src/DefaultNodesTranslator.ts | 3 ++- src/__tests__/NodesTranslator.test.ts | 4 ++-- src/types.ts | 1 - 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/DOMTranslator.ts b/src/DOMTranslator.ts index e6cee1b..e487d5f 100644 --- a/src/DOMTranslator.ts +++ b/src/DOMTranslator.ts @@ -1,8 +1,9 @@ import { TranslatableNodePredicate } from './TranslationDispatcher'; -import { TranslatorInterface } from './types'; import { isInViewport } from './utils/isInViewport'; import { visitWholeTree } from './utils/visitWholeTree'; +export type TranslatorInterface = (text: string, priority: number) => Promise; + interface NodeData { /** * Unique node identifier diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 205bd82..c556801 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -1,5 +1,6 @@ +import { TranslatorInterface } from './DOMTranslator'; import { NodesTranslator } from './NodesTranslator'; -import { Config, TranslatorInterface } from './types'; +import { Config } from './types'; import { configureTranslatableNodePredicate } from './utils/nodes'; import { DOMTranslator, IntersectionObserverWithFilter, TranslationDispatcher } from '.'; diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index d1b9043..62cd8e8 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -1,10 +1,10 @@ import { readFileSync } from 'fs'; -import { DOMTranslator } from '../DOMTranslator'; +import { DOMTranslator , TranslatorInterface } from '../DOMTranslator'; import { IntersectionObserverWithFilter } from '../IntersectionObserverWithFilter'; import { NodesTranslator } from '../NodesTranslator'; import { TranslationDispatcher } from '../TranslationDispatcher'; -import { Config, TranslatorInterface } from '../types'; +import { Config } from '../types'; import { configureTranslatableNodePredicate, NodesFilterOptions } from '../utils/nodes'; require('intersection-observer'); diff --git a/src/types.ts b/src/types.ts index ef44915..0c4352c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,4 +4,3 @@ export interface Config { isTranslatableNode?: TranslatableNodePredicate; lazyTranslate?: boolean; } -export type TranslatorInterface = (text: string, priority: number) => Promise; From d514b26ae81f7158ea1c1c439f11e731a6748209 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 30 Apr 2025 02:14:14 +0200 Subject: [PATCH 099/313] chore: move --- src/DefaultNodesTranslator.ts | 13 +++++++++++-- src/__tests__/NodesTranslator.test.ts | 4 ++-- src/types.ts | 6 ------ 3 files changed, 13 insertions(+), 10 deletions(-) delete mode 100644 src/types.ts diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index c556801..2b77bcc 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -1,8 +1,17 @@ import { TranslatorInterface } from './DOMTranslator'; import { NodesTranslator } from './NodesTranslator'; -import { Config } from './types'; import { configureTranslatableNodePredicate } from './utils/nodes'; -import { DOMTranslator, IntersectionObserverWithFilter, TranslationDispatcher } from '.'; +import { + DOMTranslator, + IntersectionObserverWithFilter, + TranslatableNodePredicate, + TranslationDispatcher, +} from '.'; + +export interface Config { + isTranslatableNode?: TranslatableNodePredicate; + lazyTranslate?: boolean; +} /** * Module for dynamic translate a DOM nodes. diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 62cd8e8..fa20a9e 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -1,10 +1,10 @@ import { readFileSync } from 'fs'; -import { DOMTranslator , TranslatorInterface } from '../DOMTranslator'; +import { Config } from '../DefaultNodesTranslator'; +import { DOMTranslator, TranslatorInterface } from '../DOMTranslator'; import { IntersectionObserverWithFilter } from '../IntersectionObserverWithFilter'; import { NodesTranslator } from '../NodesTranslator'; import { TranslationDispatcher } from '../TranslationDispatcher'; -import { Config } from '../types'; import { configureTranslatableNodePredicate, NodesFilterOptions } from '../utils/nodes'; require('intersection-observer'); diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 0c4352c..0000000 --- a/src/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { TranslatableNodePredicate } from './TranslationDispatcher'; - -export interface Config { - isTranslatableNode?: TranslatableNodePredicate; - lazyTranslate?: boolean; -} From 836d083cf28c4f97940a59b03a6cd16fd016c935 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 30 Apr 2025 02:29:21 +0200 Subject: [PATCH 100/313] chore: delete unnecessary entity --- src/__tests__/IntersectionObserverWithFilter.test.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/__tests__/IntersectionObserverWithFilter.test.ts b/src/__tests__/IntersectionObserverWithFilter.test.ts index 2a4b3ab..73afb65 100644 --- a/src/__tests__/IntersectionObserverWithFilter.test.ts +++ b/src/__tests__/IntersectionObserverWithFilter.test.ts @@ -7,8 +7,6 @@ const translator = vi.fn().mockImplementation(async (node: Node) => { node.textContent += TRANSLATION_SYMBOL; }); -const isTranslatableNode = () => true; - // jsdom does not actually modify element coordinates // Create a mock that sets the real values for the coordinates // DOMRect interface requires the toJSON property, this is not necessary for our tests, so use Omit utility type @@ -42,7 +40,7 @@ test('Call onIntersected for node from viewport', async () => { document.body.appendChild(div); const lazyTranslator = new IntersectionObserverWithFilter({ - filter: isTranslatableNode, + filter: Boolean, onIntersected: translator, }); @@ -56,7 +54,7 @@ test('Call onIntersected for node from viewport', async () => { test('Call onIntersected for a node only when it becomes intersectable', async () => { const lazyTranslator = new IntersectionObserverWithFilter({ - filter: isTranslatableNode, + filter: Boolean, onIntersected: translator, }); @@ -89,7 +87,7 @@ test('Call onIntersected for a node only when it becomes intersectable', async ( test('Not call onIntersected after node is detached', async () => { const lazyTranslator = new IntersectionObserverWithFilter({ - filter: isTranslatableNode, + filter: Boolean, onIntersected: translator, }); @@ -117,7 +115,7 @@ test('Not call onIntersected after node is detached', async () => { test('Call onIntersected only after node intersect viewport', async () => { const lazyTranslator = new IntersectionObserverWithFilter({ - filter: isTranslatableNode, + filter: Boolean, onIntersected: translator, }); const div = document.createElement('div'); @@ -177,7 +175,7 @@ test('Call onIntersected only after node intersect viewport', async () => { test('Not call a onIntersected for node that not intersect viewport after scrolling', async () => { const lazyTranslator = new IntersectionObserverWithFilter({ - filter: isTranslatableNode, + filter: Boolean, onIntersected: translator, }); const div = document.createElement('div'); From 47a28b729ca5083d1b4cfa850aa72aaa3b1c19e6 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 30 Apr 2025 03:05:12 +0200 Subject: [PATCH 101/313] test: rename test name --- src/__tests__/TranslationDispatcher.test.ts | 30 ++++++++++++--------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 2020bee..fac8055 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -5,7 +5,10 @@ import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from require('intersection-observer'); -const lazyTranslatorSpy = vi.spyOn(IntersectionObserverWithFilter.prototype, 'attach'); +const intersectionObserverSpy = vi.spyOn( + IntersectionObserverWithFilter.prototype, + 'attach', +); beforeEach(() => { vi.clearAllMocks(); @@ -26,7 +29,7 @@ function createClassDependency( return { intersectionObserver, domTranslator }; } -test('Translate node immediately', async () => { +test('Not use intersectionObserver for not intersectedle node', async () => { const config = { isTranslatableNode: () => true, lazyTranslate: true, @@ -41,18 +44,19 @@ test('Translate node immediately', async () => { lazyDOMTranslator: intersectionObserver, }); - // Node not to attach the DOM, is can`t translate lazy, but should translated immediately - const div = document.createElement('div'); - div.innerHTML = 'Hello, world!'; - translationDispatcher.translateNode(div); + // OPTION node is not intersectible, node can`t translate 'lazy' + const node = document.createElement('option'); + node.innerHTML = 'Hello, world!'; + document.body.appendChild(node); + translationDispatcher.translateNode(node); await awaitTranslation(); // lazy translator not called - expect(lazyTranslatorSpy).toBeCalledTimes(0); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(intersectionObserverSpy).toBeCalledTimes(0); + expect(node.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Translate node lazy', async () => { +test('Call IntersectionObserver for deferred translation of intersecting node', async () => { const config = { isTranslatableNode: () => true, lazyTranslate: true, @@ -74,11 +78,11 @@ test('Translate node lazy', async () => { await awaitTranslation(); // lazy translator called - expect(lazyTranslatorSpy.mock.calls).toEqual([[div]]); + expect(intersectionObserverSpy.mock.calls).toEqual([[div]]); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Translate immediately with lazyTranslate false', async () => { +test('Not use lazy strategy with falsy lazyTranslate param', async () => { const config = { isTranslatableNode: () => true, lazyTranslate: false, @@ -100,11 +104,11 @@ test('Translate immediately with lazyTranslate false', async () => { await awaitTranslation(); // lazy translator not called - expect(lazyTranslatorSpy.mock.calls).toEqual([]); + expect(intersectionObserverSpy.mock.calls).toEqual([]); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Translate the entire node tree', async () => { +test('Translates entire DOM subtree', async () => { const config = { isTranslatableNode: () => true, lazyTranslate: false, From 98f0e3de89c269b1520b8bbdaf286eae1b7b8d2b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 30 Apr 2025 03:12:35 +0200 Subject: [PATCH 102/313] test: delete test case --- src/__tests__/TranslationDispatcher.test.ts | 35 +-------------------- 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index fac8055..0fee64f 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -29,7 +29,7 @@ function createClassDependency( return { intersectionObserver, domTranslator }; } -test('Not use intersectionObserver for not intersectedle node', async () => { +test('Not call intersectionObserver for not intersectedle node', async () => { const config = { isTranslatableNode: () => true, lazyTranslate: true, @@ -107,36 +107,3 @@ test('Not use lazy strategy with falsy lazyTranslate param', async () => { expect(intersectionObserverSpy.mock.calls).toEqual([]); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); - -test('Translates entire DOM subtree', async () => { - const config = { - isTranslatableNode: () => true, - lazyTranslate: false, - }; - const { domTranslator, intersectionObserver } = createClassDependency( - config.isTranslatableNode, - translator, - ); - const translationDispatcher = new TranslationDispatcher({ - config, - domTranslator: domTranslator, - lazyDOMTranslator: intersectionObserver, - }); - - const div = document.createElement('div'); - div.innerHTML = 'Hello'; - const div1 = document.createElement('div'); - div1.innerHTML = 'Hello world'; - const p = document.createElement('p'); - p.innerHTML = 'I`m a fox'; - div1.appendChild(p); - div.appendChild(div1); - - translationDispatcher.translateNode(div1); - await awaitTranslation(); - - // all nodes was translated - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(div1.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(p.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); -}); From 2a4b4a8c98238e024f0b99aba1b5c0374ddf5fb2 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 30 Apr 2025 03:20:36 +0200 Subject: [PATCH 103/313] chore: improve description --- src/IntersectionObserverWithFilter.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/IntersectionObserverWithFilter.ts b/src/IntersectionObserverWithFilter.ts index 4b1036d..ff5d173 100644 --- a/src/IntersectionObserverWithFilter.ts +++ b/src/IntersectionObserverWithFilter.ts @@ -1,7 +1,7 @@ import { TranslatableNodePredicate } from './TranslationDispatcher'; /** - * Call the provided callback when the node intersects the viewport + * Observe DOM nodes and call a callback for filtered nodes when they intersect the viewport */ export class IntersectionObserverWithFilter { // Store the nodes that is under observing for intersection @@ -51,7 +51,6 @@ export class IntersectionObserverWithFilter { public detach(node: Element) { this.nodesObservedForIntersection.delete(node); - this.intersectionObserver.unobserve(node); } From b1fc6485931a42dbd6b77b0850631e92530d13ad Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 30 Apr 2025 03:48:04 +0200 Subject: [PATCH 104/313] test: delete test case --- src/__tests__/DOMTranslator.test.ts | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/src/__tests__/DOMTranslator.test.ts b/src/__tests__/DOMTranslator.test.ts index 0f64df9..5813346 100644 --- a/src/__tests__/DOMTranslator.test.ts +++ b/src/__tests__/DOMTranslator.test.ts @@ -44,7 +44,7 @@ test('Translate and restore original node text', async () => { expect(div.innerHTML).toMatch(originElementText); }); -test('Get original node text', async () => { +test('Returns original text node after translation', async () => { const domTranslator = new DOMTranslator({ isTranslatableNode: Boolean, translateCallback: translator, @@ -81,7 +81,7 @@ test('Translated node has in the storage', async () => { expect(domTranslator.hasNode(div.childNodes[0])).toBe(false); }); -test('Update translation for node', async () => { +test('Calls updateNode when node content is updated', async () => { const domTranslator = new DOMTranslator({ isTranslatableNode: Boolean, translateCallback: translator, @@ -159,27 +159,3 @@ test('Restore translations from all nested nodes in the element', async () => { expect(parentDiv.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(childDiv.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); - -test('Delete translation only from target element', async () => { - const domTranslator = new DOMTranslator({ - isTranslatableNode: Boolean, - translateCallback: translator, - }); - const parentDiv = document.createElement('div'); - parentDiv.innerHTML = 'Hello world!'; - const childDiv = document.createElement('div'); - childDiv.innerHTML = 'Hello world too!'; - parentDiv.append(childDiv); - - handleElementTree(parentDiv, domTranslator.translateNode); - await awaitTranslation(); - - domTranslator.restoreNode(parentDiv.childNodes[0], true); - - //target element has not translation - expect(parentDiv.childNodes[0].textContent).not.toMatch( - containsRegex(TRANSLATION_SYMBOL), - ); - // child element still has translation - expect(childDiv.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); -}); From 1749261facfca59cc9929eda0b3702a7cba9eaca Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 30 Apr 2025 03:49:56 +0200 Subject: [PATCH 105/313] test: move to test body --- src/__tests__/DOMTranslator.test.ts | 40 +++++++++++++---------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/src/__tests__/DOMTranslator.test.ts b/src/__tests__/DOMTranslator.test.ts index 5813346..7da8f16 100644 --- a/src/__tests__/DOMTranslator.test.ts +++ b/src/__tests__/DOMTranslator.test.ts @@ -1,28 +1,6 @@ import { DOMTranslator } from '../DOMTranslator'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; -// mock for to translate the entire element tree -const handleElementTree = (node: Node, callback: (node: Node) => void) => { - if (node instanceof Element) { - vi.fn((root: Element, callback: (n: Node) => void) => { - const handel = (n: Node) => { - callback(n); - if (n instanceof Element) { - Array.from(n.childNodes).forEach(handel); - Array.from(n.attributes).forEach(callback); - } - }; - handel(root); - })(node, (node) => { - callback(node); - }); - } -}; - -beforeEach(() => { - vi.clearAllMocks(); -}); - test('Translate and restore original node text', async () => { const domTranslator = new DOMTranslator({ isTranslatableNode: Boolean, @@ -140,6 +118,24 @@ test('Restored node contain the most recent content after few translate', async }); test('Restore translations from all nested nodes in the element', async () => { + // mock for to translate the entire element tree + const handleElementTree = (node: Node, callback: (node: Node) => void) => { + if (node instanceof Element) { + vi.fn((root: Element, callback: (n: Node) => void) => { + const handel = (n: Node) => { + callback(n); + if (n instanceof Element) { + Array.from(n.childNodes).forEach(handel); + Array.from(n.attributes).forEach(callback); + } + }; + handel(root); + })(node, (node) => { + callback(node); + }); + } + }; + const domTranslator = new DOMTranslator({ isTranslatableNode: Boolean, translateCallback: translator, From 3bd2253ffd5bb139092c856e8e86ab83f67aa10c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 30 Apr 2025 03:53:26 +0200 Subject: [PATCH 106/313] test: delete unnecessary code --- src/__tests__/TranslationDispatcher.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 0fee64f..c8cd2e8 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -10,10 +10,6 @@ const intersectionObserverSpy = vi.spyOn( 'attach', ); -beforeEach(() => { - vi.clearAllMocks(); -}); - function createClassDependency( isTranslatableNode: (node: Node) => boolean, translateCallback: (text: string) => Promise, From 654272be2ae8b5156aaf29c5fa3e90c7c5a33fb3 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 30 Apr 2025 04:02:10 +0200 Subject: [PATCH 107/313] Revert "test: delete unnecessary code" This reverts commit ffb03214dd08975a4c1157d5760b20016348d147. --- src/__tests__/TranslationDispatcher.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index c8cd2e8..0fee64f 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -10,6 +10,10 @@ const intersectionObserverSpy = vi.spyOn( 'attach', ); +beforeEach(() => { + vi.clearAllMocks(); +}); + function createClassDependency( isTranslatableNode: (node: Node) => boolean, translateCallback: (text: string) => Promise, From 13ae3c22ae4df588ed85b2c1b71fd9a8187162aa Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 5 May 2025 14:07:31 +0200 Subject: [PATCH 108/313] refactor: move element processing --- src/DOMTranslator.ts | 11 +------- src/TranslationDispatcher.ts | 13 +++++++++- src/__tests__/DOMTranslator.test.ts | 39 ----------------------------- 3 files changed, 13 insertions(+), 50 deletions(-) diff --git a/src/DOMTranslator.ts b/src/DOMTranslator.ts index e487d5f..c631601 100644 --- a/src/DOMTranslator.ts +++ b/src/DOMTranslator.ts @@ -1,6 +1,5 @@ import { TranslatableNodePredicate } from './TranslationDispatcher'; import { isInViewport } from './utils/isInViewport'; -import { visitWholeTree } from './utils/visitWholeTree'; export type TranslatorInterface = (text: string, priority: number) => Promise; @@ -110,16 +109,8 @@ export class DOMTranslator { /** * Restores the original node text - * @param onlyTarget determines whether only the target node or all its nested nodes will be restored */ - public restoreNode(node: Node, onlyTarget = false) { - // Delete all attributes and inner nodes - if (node instanceof Element && !onlyTarget) { - visitWholeTree(node, (node) => { - this.restoreNode(node, true); - }); - } - + public restoreNode(node: Node) { const nodeData = this.nodeStorage.get(node); if (nodeData == undefined) { return; diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 032bc11..2ae972c 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -73,7 +73,18 @@ export class TranslationDispatcher { this.domTranslator.translateNode(node); } - public restoreNode(node: Node) { + /** + * Restores the original node text + * @param onlyTarget determines whether only the target node or all its nested nodes will be restored + */ + public restoreNode(node: Node, onlyTarget = false) { + // Delete all attributes and inner nodes + if (node instanceof Element && !onlyTarget) { + visitWholeTree(node, (node) => { + this.restoreNode(node, true); + }); + } + this.domTranslator.restoreNode(node); if (node instanceof Element) { diff --git a/src/__tests__/DOMTranslator.test.ts b/src/__tests__/DOMTranslator.test.ts index 7da8f16..8125daa 100644 --- a/src/__tests__/DOMTranslator.test.ts +++ b/src/__tests__/DOMTranslator.test.ts @@ -116,42 +116,3 @@ test('Restored node contain the most recent content after few translate', async expect(div.innerHTML).toMatch(newText); expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); - -test('Restore translations from all nested nodes in the element', async () => { - // mock for to translate the entire element tree - const handleElementTree = (node: Node, callback: (node: Node) => void) => { - if (node instanceof Element) { - vi.fn((root: Element, callback: (n: Node) => void) => { - const handel = (n: Node) => { - callback(n); - if (n instanceof Element) { - Array.from(n.childNodes).forEach(handel); - Array.from(n.attributes).forEach(callback); - } - }; - handel(root); - })(node, (node) => { - callback(node); - }); - } - }; - - const domTranslator = new DOMTranslator({ - isTranslatableNode: Boolean, - translateCallback: translator, - }); - const parentDiv = document.createElement('div'); - parentDiv.innerHTML = 'Hello world!'; - const childDiv = document.createElement('div'); - childDiv.innerHTML = 'Hello world too!'; - parentDiv.append(childDiv); - - handleElementTree(parentDiv, domTranslator.translateNode); - await awaitTranslation(); - - domTranslator.restoreNode(parentDiv); - - // child node and target has not translated text - expect(parentDiv.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(childDiv.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); -}); From 64736e278aac0fbcaf7cb5c73a693b2e6ede46f6 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 5 May 2025 14:35:37 +0200 Subject: [PATCH 109/313] chore: rename --- src/{DOMTranslator.ts => DOMNodesTranslator.ts} | 2 +- src/DefaultNodesTranslator.ts | 6 +++--- src/NodesTranslator.ts | 4 ++-- src/TranslationDispatcher.ts | 6 +++--- src/__tests__/DOMTranslator.test.ts | 14 +++++++------- src/__tests__/NodesTranslator.test.ts | 4 ++-- src/__tests__/TranslationDispatcher.test.ts | 4 ++-- src/index.ts | 2 +- 8 files changed, 21 insertions(+), 21 deletions(-) rename src/{DOMTranslator.ts => DOMNodesTranslator.ts} (99%) diff --git a/src/DOMTranslator.ts b/src/DOMNodesTranslator.ts similarity index 99% rename from src/DOMTranslator.ts rename to src/DOMNodesTranslator.ts index c631601..3ac0ab8 100644 --- a/src/DOMTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -60,7 +60,7 @@ function getNodePriority(node: Node) { * Manages a translation state of DOM nodes, registers nodes and initiates translation. * Updates the translation when a node is modified or deleted */ -export class DOMTranslator { +export class DOMNodesTranslator { private idCounter = 0; private nodeStorage = new WeakMap(); diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 2b77bcc..8355d27 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -1,8 +1,8 @@ -import { TranslatorInterface } from './DOMTranslator'; +import { TranslatorInterface } from './DOMNodesTranslator'; import { NodesTranslator } from './NodesTranslator'; import { configureTranslatableNodePredicate } from './utils/nodes'; import { - DOMTranslator, + DOMNodesTranslator, IntersectionObserverWithFilter, TranslatableNodePredicate, TranslationDispatcher, @@ -27,7 +27,7 @@ export class DefaultNodesTranslator extends NodesTranslator { config?.lazyTranslate !== undefined ? config?.lazyTranslate : true, }; - const domTranslator = new DOMTranslator({ + const domTranslator = new DOMNodesTranslator({ isTranslatableNode: innerConfig.isTranslatableNode, translateCallback, }); diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index ed00ab9..7e5b986 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,4 +1,4 @@ -import { DOMTranslator } from './DOMTranslator'; +import { DOMNodesTranslator } from './DOMNodesTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; import { TranslationDispatcher } from './TranslationDispatcher'; @@ -18,7 +18,7 @@ export class NodesTranslator { domTranslator, }: { translatorDispatcher: TranslationDispatcher; - domTranslator: DOMTranslator; + domTranslator: DOMNodesTranslator; }) { this.translatorDispatcher = translatorDispatcher; this.domTranslator = domTranslator; diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 2ae972c..89b0158 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -1,4 +1,4 @@ -import { DOMTranslator } from './DOMTranslator'; +import { DOMNodesTranslator } from './DOMNodesTranslator'; import { IntersectionObserverWithFilter } from './IntersectionObserverWithFilter'; import { isIntersectableNode } from './utils/isIntersectableNode'; import { visitWholeTree } from './utils/visitWholeTree'; @@ -10,7 +10,7 @@ export type TranslatableNodePredicate = (node: Node) => boolean; */ export class TranslationDispatcher { private readonly config; - private readonly domTranslator: DOMTranslator; + private readonly domTranslator: DOMNodesTranslator; private readonly lazyDOMTranslator: IntersectionObserverWithFilter; constructor({ @@ -22,7 +22,7 @@ export class TranslationDispatcher { isTranslatableNode: TranslatableNodePredicate; lazyTranslate: boolean; }; - domTranslator: DOMTranslator; + domTranslator: DOMNodesTranslator; lazyDOMTranslator: IntersectionObserverWithFilter; }) { this.config = config; diff --git a/src/__tests__/DOMTranslator.test.ts b/src/__tests__/DOMTranslator.test.ts index 8125daa..559e80c 100644 --- a/src/__tests__/DOMTranslator.test.ts +++ b/src/__tests__/DOMTranslator.test.ts @@ -1,8 +1,8 @@ -import { DOMTranslator } from '../DOMTranslator'; +import { DOMNodesTranslator } from '../DOMNodesTranslator'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; test('Translate and restore original node text', async () => { - const domTranslator = new DOMTranslator({ + const domTranslator = new DOMNodesTranslator({ isTranslatableNode: Boolean, translateCallback: translator, }); @@ -23,7 +23,7 @@ test('Translate and restore original node text', async () => { }); test('Returns original text node after translation', async () => { - const domTranslator = new DOMTranslator({ + const domTranslator = new DOMNodesTranslator({ isTranslatableNode: Boolean, translateCallback: translator, }); @@ -42,7 +42,7 @@ test('Returns original text node after translation', async () => { }); test('Translated node has in the storage', async () => { - const domTranslator = new DOMTranslator({ + const domTranslator = new DOMNodesTranslator({ isTranslatableNode: Boolean, translateCallback: translator, }); @@ -60,12 +60,12 @@ test('Translated node has in the storage', async () => { }); test('Calls updateNode when node content is updated', async () => { - const domTranslator = new DOMTranslator({ + const domTranslator = new DOMNodesTranslator({ isTranslatableNode: Boolean, translateCallback: translator, }); // spy on the updateNode method - const updateNodesSpy = vi.spyOn(domTranslator as DOMTranslator, 'updateNode'); + const updateNodesSpy = vi.spyOn(domTranslator as DOMNodesTranslator, 'updateNode'); const text = 'Hello world!'; const div = document.createElement('div'); @@ -94,7 +94,7 @@ test('Calls updateNode when node content is updated', async () => { }); test('Restored node contain the most recent content after few translate', async () => { - const domTranslator = new DOMTranslator({ + const domTranslator = new DOMNodesTranslator({ isTranslatableNode: Boolean, translateCallback: translator, }); diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index fa20a9e..44cf890 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'fs'; import { Config } from '../DefaultNodesTranslator'; -import { DOMTranslator, TranslatorInterface } from '../DOMTranslator'; +import { DOMNodesTranslator, TranslatorInterface } from '../DOMNodesTranslator'; import { IntersectionObserverWithFilter } from '../IntersectionObserverWithFilter'; import { NodesTranslator } from '../NodesTranslator'; import { TranslationDispatcher } from '../TranslationDispatcher'; @@ -40,7 +40,7 @@ function buildClass(translateCallback: TranslatorInterface, config?: Config) { lazyTranslate: config?.lazyTranslate !== undefined ? config?.lazyTranslate : true, }; - const domTranslator = new DOMTranslator({ + const domTranslator = new DOMNodesTranslator({ isTranslatableNode: innerConfig.isTranslatableNode, translateCallback, }); diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 0fee64f..2a9bd63 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -1,4 +1,4 @@ -import { DOMTranslator } from '../DOMTranslator'; +import { DOMNodesTranslator } from '../DOMNodesTranslator'; import { IntersectionObserverWithFilter } from '../IntersectionObserverWithFilter'; import { TranslationDispatcher } from '../TranslationDispatcher'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; @@ -18,7 +18,7 @@ function createClassDependency( isTranslatableNode: (node: Node) => boolean, translateCallback: (text: string) => Promise, ) { - const domTranslator = new DOMTranslator({ + const domTranslator = new DOMNodesTranslator({ isTranslatableNode: isTranslatableNode, translateCallback, }); diff --git a/src/index.ts b/src/index.ts index 9b5627c..51851f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export * from './NodesTranslator'; export * from './TranslationDispatcher'; -export * from './DOMTranslator'; +export * from './DOMNodesTranslator'; export * from './IntersectionObserverWithFilter'; export * from './DefaultNodesTranslator'; From d591186e28ce06ba0ca4b4a11a84aa87e75d60f6 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 5 May 2025 14:40:15 +0200 Subject: [PATCH 110/313] chore: update docs --- src/DOMNodesTranslator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 3ac0ab8..9a93431 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -57,8 +57,8 @@ function getNodePriority(node: Node) { } /** - * Manages a translation state of DOM nodes, registers nodes and initiates translation. - * Updates the translation when a node is modified or deleted + * Manages a translation state of DOM nodes. Processing text-containing nodes (Text, Attr, etc). + * Registers nodes and initiates translation, updates the translation when a node is modified or deleted. */ export class DOMNodesTranslator { private idCounter = 0; From fe3fd8a7531b1cbe9ca423ec9cb3996b8d69384e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 5 May 2025 15:21:05 +0200 Subject: [PATCH 111/313] refactor: use class as an optional dependency --- src/TranslationDispatcher.ts | 11 ++- src/__tests__/NodesTranslator.test.ts | 77 ++++++++++----------- src/__tests__/TranslationDispatcher.test.ts | 68 +++++++----------- 3 files changed, 68 insertions(+), 88 deletions(-) diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 89b0158..0bcdabb 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -11,7 +11,7 @@ export type TranslatableNodePredicate = (node: Node) => boolean; export class TranslationDispatcher { private readonly config; private readonly domTranslator: DOMNodesTranslator; - private readonly lazyDOMTranslator: IntersectionObserverWithFilter; + private readonly lazyDOMTranslator: IntersectionObserverWithFilter | null; constructor({ config, @@ -20,14 +20,13 @@ export class TranslationDispatcher { }: { config: { isTranslatableNode: TranslatableNodePredicate; - lazyTranslate: boolean; }; domTranslator: DOMNodesTranslator; - lazyDOMTranslator: IntersectionObserverWithFilter; + lazyDOMTranslator?: IntersectionObserverWithFilter; }) { this.config = config; this.domTranslator = domTranslator; - this.lazyDOMTranslator = lazyDOMTranslator; + this.lazyDOMTranslator = lazyDOMTranslator || null; } public updateNode(node: Node) { @@ -52,7 +51,7 @@ export class TranslationDispatcher { } // translate later or immediately - if (this.config.lazyTranslate) { + if (this.lazyDOMTranslator) { // Lazy translate when own element intersect viewport // But translate at once if node have not parent (virtual node) or parent node is outside of body (utility tags like meta or title) const isAttachedToDOM = node.getRootNode() !== node; @@ -87,7 +86,7 @@ export class TranslationDispatcher { this.domTranslator.restoreNode(node); - if (node instanceof Element) { + if (this.lazyDOMTranslator && node instanceof Element) { this.lazyDOMTranslator.detach(node); } } diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 44cf890..2b5b7c0 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -32,31 +32,35 @@ const fillDocument = (text: string) => { document.write(text); }; -function buildClass(translateCallback: TranslatorInterface, config?: Config) { - const innerConfig = { - ...config, - isTranslatableNode: - config?.isTranslatableNode ?? configureTranslatableNodePredicate(), - lazyTranslate: config?.lazyTranslate !== undefined ? config?.lazyTranslate : true, - }; - - const domTranslator = new DOMNodesTranslator({ - isTranslatableNode: innerConfig.isTranslatableNode, +function buildTranslationServices( + translateCallback: TranslatorInterface, + config: { lazyTranslate: boolean; isTranslatableNode?: (node: Node) => boolean }, +) { + const isTranslatableNode = + config.isTranslatableNode ?? configureTranslatableNodePredicate(); + + const domNodeTranslator = new DOMNodesTranslator({ + isTranslatableNode: isTranslatableNode, translateCallback, }); - const lazyDOMTranslator = new IntersectionObserverWithFilter({ - filter: innerConfig.isTranslatableNode, - onIntersected: domTranslator.translateNode, + // enable intersectionObserver if the lazyTranslate parameter is passed + const intersectionObserverWithFilter = config.lazyTranslate + ? new IntersectionObserverWithFilter({ + filter: isTranslatableNode, + onIntersected: domNodeTranslator.translateNode, + }) + : undefined; + + const translatorDispatcher = new TranslationDispatcher({ + config: { isTranslatableNode }, + domTranslator: domNodeTranslator, + lazyDOMTranslator: intersectionObserverWithFilter, }); return { - domNodeTranslator: domTranslator, - translatorDispatcher: new TranslationDispatcher({ - config: innerConfig, - domTranslator: domTranslator, - lazyDOMTranslator: lazyDOMTranslator, - }), + domNodeTranslator, + translatorDispatcher, }; } @@ -73,9 +77,12 @@ describe('basic usage', () => { const parsedHTML = document.documentElement.outerHTML; // Translate document - const { translatorDispatcher, domNodeTranslator } = buildClass(translator, { - lazyTranslate, - }); + const { translatorDispatcher, domNodeTranslator } = buildTranslationServices( + translator, + { + lazyTranslate, + }, + ); const domTranslator = new NodesTranslator({ translatorDispatcher, domTranslator: domNodeTranslator, @@ -128,10 +135,8 @@ describe('basic usage', () => { const parsedHTML = document.documentElement.outerHTML; // Translate document - const { translatorDispatcher, domNodeTranslator } = buildClass( - translator, - options, - ); + const { translatorDispatcher, domNodeTranslator } = + buildTranslationServices(translator, options); const domTranslator = new NodesTranslator({ translatorDispatcher, domTranslator: domNodeTranslator, @@ -150,10 +155,8 @@ describe('basic usage', () => { fillDocument(sample); // Translate document - const { translatorDispatcher, domNodeTranslator } = buildClass( - translator, - options, - ); + const { translatorDispatcher, domNodeTranslator } = + buildTranslationServices(translator, options); const domTranslator = new NodesTranslator({ translatorDispatcher, domTranslator: domNodeTranslator, @@ -211,10 +214,8 @@ describe('basic usage', () => { fillDocument(sample); // Translate document - const { translatorDispatcher, domNodeTranslator } = buildClass( - translator, - options, - ); + const { translatorDispatcher, domNodeTranslator } = + buildTranslationServices(translator, options); const domTranslator = new NodesTranslator({ translatorDispatcher, domTranslator: domNodeTranslator, @@ -264,9 +265,8 @@ describe('basic usage', () => { fillDocument(sample); // Translate document - const { translatorDispatcher, domNodeTranslator } = buildClass( - translator, - { + const { translatorDispatcher, domNodeTranslator } = + buildTranslationServices(translator, { ...options, isTranslatableNode: configureTranslatableNodePredicate({ ...filterOptions, @@ -276,8 +276,7 @@ describe('basic usage', () => { '.custom-elements :checked', ], }), - }, - ); + }); const domTranslator = new NodesTranslator({ translatorDispatcher, domTranslator: domNodeTranslator, diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 2a9bd63..9df9072 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -14,34 +14,21 @@ beforeEach(() => { vi.clearAllMocks(); }); -function createClassDependency( - isTranslatableNode: (node: Node) => boolean, - translateCallback: (text: string) => Promise, -) { +const isTranslatableNode = () => true; + +test('Not call intersectionObserver for not intersectedle node', async () => { const domTranslator = new DOMNodesTranslator({ isTranslatableNode: isTranslatableNode, - translateCallback, + translateCallback: translator, }); - const intersectionObserver = new IntersectionObserverWithFilter({ - filter: isTranslatableNode, - onIntersected: domTranslator.translateNode, - }); - return { intersectionObserver, domTranslator }; -} - -test('Not call intersectionObserver for not intersectedle node', async () => { - const config = { - isTranslatableNode: () => true, - lazyTranslate: true, - }; - const { domTranslator, intersectionObserver } = createClassDependency( - config.isTranslatableNode, - translator, - ); const translationDispatcher = new TranslationDispatcher({ - config, + config: { isTranslatableNode }, domTranslator: domTranslator, - lazyDOMTranslator: intersectionObserver, + + lazyDOMTranslator: new IntersectionObserverWithFilter({ + filter: isTranslatableNode, + onIntersected: domTranslator.translateNode, + }), }); // OPTION node is not intersectible, node can`t translate 'lazy' @@ -57,18 +44,18 @@ test('Not call intersectionObserver for not intersectedle node', async () => { }); test('Call IntersectionObserver for deferred translation of intersecting node', async () => { - const config = { - isTranslatableNode: () => true, - lazyTranslate: true, - }; - const { domTranslator, intersectionObserver } = createClassDependency( - config.isTranslatableNode, - translator, - ); + const domTranslator = new DOMNodesTranslator({ + isTranslatableNode: isTranslatableNode, + translateCallback: translator, + }); const translationDispatcher = new TranslationDispatcher({ - config, + config: { isTranslatableNode }, domTranslator: domTranslator, - lazyDOMTranslator: intersectionObserver, + + lazyDOMTranslator: new IntersectionObserverWithFilter({ + filter: isTranslatableNode, + onIntersected: domTranslator.translateNode, + }), }); const div = document.createElement('div'); @@ -83,18 +70,13 @@ test('Call IntersectionObserver for deferred translation of intersecting node', }); test('Not use lazy strategy with falsy lazyTranslate param', async () => { - const config = { - isTranslatableNode: () => true, - lazyTranslate: false, - }; - const { domTranslator, intersectionObserver } = createClassDependency( - config.isTranslatableNode, - translator, - ); + const domTranslator = new DOMNodesTranslator({ + isTranslatableNode: isTranslatableNode, + translateCallback: translator, + }); const translationDispatcher = new TranslationDispatcher({ - config, + config: { isTranslatableNode }, domTranslator: domTranslator, - lazyDOMTranslator: intersectionObserver, }); const div = document.createElement('div'); From 4a3ed3bddfba47e92b057b516196b6c5dd4c5c23 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 5 May 2025 15:25:15 +0200 Subject: [PATCH 112/313] refactor: simplify param --- src/DefaultNodesTranslator.ts | 18 ++++++++++-------- src/TranslationDispatcher.ts | 13 ++++++------- src/__tests__/NodesTranslator.test.ts | 4 ++-- src/__tests__/TranslationDispatcher.test.ts | 6 +++--- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 8355d27..970d8ba 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -20,7 +20,6 @@ export interface Config { export class DefaultNodesTranslator extends NodesTranslator { constructor(translateCallback: TranslatorInterface, config?: Config) { const innerConfig = { - ...config, isTranslatableNode: config?.isTranslatableNode ?? configureTranslatableNodePredicate(), lazyTranslate: @@ -32,16 +31,19 @@ export class DefaultNodesTranslator extends NodesTranslator { translateCallback, }); - const lazyDOMTranslator = new IntersectionObserverWithFilter({ - filter: innerConfig.isTranslatableNode, - onIntersected: domTranslator.translateNode, - }); + // not create instance if param lazyTranslate falsy + const lazyDOMTranslator = innerConfig.lazyTranslate + ? new IntersectionObserverWithFilter({ + filter: innerConfig.isTranslatableNode, + onIntersected: domTranslator.translateNode, + }) + : undefined; super({ translatorDispatcher: new TranslationDispatcher({ - config: innerConfig, - domTranslator: domTranslator, - lazyDOMTranslator: lazyDOMTranslator, + isTranslatableNode: innerConfig.isTranslatableNode, + domTranslator, + lazyDOMTranslator, }), domTranslator, }); diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 0bcdabb..74b95a8 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -9,22 +9,21 @@ export type TranslatableNodePredicate = (node: Node) => boolean; * Class coordinates the processing of DOM nodes for translation. Choose translation strategy: lazy or immediate. */ export class TranslationDispatcher { - private readonly config; + private readonly isTranslatableNode: TranslatableNodePredicate; private readonly domTranslator: DOMNodesTranslator; + // if dependency is not passed, then the node will not be translated lazy private readonly lazyDOMTranslator: IntersectionObserverWithFilter | null; constructor({ - config, + isTranslatableNode, domTranslator, lazyDOMTranslator, }: { - config: { - isTranslatableNode: TranslatableNodePredicate; - }; + isTranslatableNode: TranslatableNodePredicate; domTranslator: DOMNodesTranslator; lazyDOMTranslator?: IntersectionObserverWithFilter; }) { - this.config = config; + this.isTranslatableNode = isTranslatableNode; this.domTranslator = domTranslator; this.lazyDOMTranslator = lazyDOMTranslator || null; } @@ -43,7 +42,7 @@ export class TranslationDispatcher { visitWholeTree(node, (node) => { if (node instanceof Element) return; - if (this.config.isTranslatableNode(node)) { + if (this.isTranslatableNode(node)) { this.translateNode(node); } }); diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 2b5b7c0..cd3130e 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -44,7 +44,7 @@ function buildTranslationServices( translateCallback, }); - // enable intersectionObserver if the lazyTranslate parameter is passed + // not create instance if param lazyTranslate falsy const intersectionObserverWithFilter = config.lazyTranslate ? new IntersectionObserverWithFilter({ filter: isTranslatableNode, @@ -53,7 +53,7 @@ function buildTranslationServices( : undefined; const translatorDispatcher = new TranslationDispatcher({ - config: { isTranslatableNode }, + isTranslatableNode, domTranslator: domNodeTranslator, lazyDOMTranslator: intersectionObserverWithFilter, }); diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 9df9072..2d4d7e1 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -22,7 +22,7 @@ test('Not call intersectionObserver for not intersectedle node', async () => { translateCallback: translator, }); const translationDispatcher = new TranslationDispatcher({ - config: { isTranslatableNode }, + isTranslatableNode, domTranslator: domTranslator, lazyDOMTranslator: new IntersectionObserverWithFilter({ @@ -49,7 +49,7 @@ test('Call IntersectionObserver for deferred translation of intersecting node', translateCallback: translator, }); const translationDispatcher = new TranslationDispatcher({ - config: { isTranslatableNode }, + isTranslatableNode, domTranslator: domTranslator, lazyDOMTranslator: new IntersectionObserverWithFilter({ @@ -75,7 +75,7 @@ test('Not use lazy strategy with falsy lazyTranslate param', async () => { translateCallback: translator, }); const translationDispatcher = new TranslationDispatcher({ - config: { isTranslatableNode }, + isTranslatableNode, domTranslator: domTranslator, }); From 2d2674e07b4cdc479d41c1800bfa9a09aa1ce504 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 6 May 2025 01:41:23 +0200 Subject: [PATCH 113/313] refactor: rename --- src/DefaultNodesTranslator.ts | 8 +++--- src/NodesTranslator.ts | 10 +++---- src/TranslationDispatcher.ts | 14 +++++----- src/__tests__/NodesTranslator.test.ts | 38 +++++++++------------------ 4 files changed, 28 insertions(+), 42 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 970d8ba..b026d0b 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -26,7 +26,7 @@ export class DefaultNodesTranslator extends NodesTranslator { config?.lazyTranslate !== undefined ? config?.lazyTranslate : true, }; - const domTranslator = new DOMNodesTranslator({ + const domNodesTranslator = new DOMNodesTranslator({ isTranslatableNode: innerConfig.isTranslatableNode, translateCallback, }); @@ -35,17 +35,17 @@ export class DefaultNodesTranslator extends NodesTranslator { const lazyDOMTranslator = innerConfig.lazyTranslate ? new IntersectionObserverWithFilter({ filter: innerConfig.isTranslatableNode, - onIntersected: domTranslator.translateNode, + onIntersected: domNodesTranslator.translateNode, }) : undefined; super({ translatorDispatcher: new TranslationDispatcher({ isTranslatableNode: innerConfig.isTranslatableNode, - domTranslator, + domTranslator: domNodesTranslator, lazyDOMTranslator, }), - domTranslator, + domNodesTranslator, }); } } diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 7e5b986..0eff65d 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -11,17 +11,17 @@ import { TranslationDispatcher } from './TranslationDispatcher'; */ export class NodesTranslator { private readonly translatorDispatcher; - private readonly domTranslator; + private readonly domNodesTranslator; constructor({ translatorDispatcher, - domTranslator, + domNodesTranslator, }: { translatorDispatcher: TranslationDispatcher; - domTranslator: DOMNodesTranslator; + domNodesTranslator: DOMNodesTranslator; }) { this.translatorDispatcher = translatorDispatcher; - this.domTranslator = domTranslator; + this.domNodesTranslator = domNodesTranslator; } private readonly observedNodesStorage = new Map(); @@ -74,6 +74,6 @@ export class NodesTranslator { } public getNodeData(node: Node) { - return this.domTranslator.getOriginalNodeText(node); + return this.domNodesTranslator.getOriginalNodeText(node); } } diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 74b95a8..2988a46 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -10,13 +10,13 @@ export type TranslatableNodePredicate = (node: Node) => boolean; */ export class TranslationDispatcher { private readonly isTranslatableNode: TranslatableNodePredicate; - private readonly domTranslator: DOMNodesTranslator; + private readonly domNodesTranslator: DOMNodesTranslator; // if dependency is not passed, then the node will not be translated lazy private readonly lazyDOMTranslator: IntersectionObserverWithFilter | null; constructor({ isTranslatableNode, - domTranslator, + domTranslator: domNodesTranslator, lazyDOMTranslator, }: { isTranslatableNode: TranslatableNodePredicate; @@ -24,16 +24,16 @@ export class TranslationDispatcher { lazyDOMTranslator?: IntersectionObserverWithFilter; }) { this.isTranslatableNode = isTranslatableNode; - this.domTranslator = domTranslator; + this.domNodesTranslator = domNodesTranslator; this.lazyDOMTranslator = lazyDOMTranslator || null; } public updateNode(node: Node) { - this.domTranslator.updateNode(node); + this.domNodesTranslator.updateNode(node); } public hasNode(node: Node) { - return this.domTranslator.hasNode(node); + return this.domNodesTranslator.hasNode(node); } public translateNode(node: Node) { @@ -68,7 +68,7 @@ export class TranslationDispatcher { } } - this.domTranslator.translateNode(node); + this.domNodesTranslator.translateNode(node); } /** @@ -83,7 +83,7 @@ export class TranslationDispatcher { }); } - this.domTranslator.restoreNode(node); + this.domNodesTranslator.restoreNode(node); if (this.lazyDOMTranslator && node instanceof Element) { this.lazyDOMTranslator.detach(node); diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index cd3130e..e7dc732 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -39,7 +39,7 @@ function buildTranslationServices( const isTranslatableNode = config.isTranslatableNode ?? configureTranslatableNodePredicate(); - const domNodeTranslator = new DOMNodesTranslator({ + const domNodesTranslator = new DOMNodesTranslator({ isTranslatableNode: isTranslatableNode, translateCallback, }); @@ -48,18 +48,18 @@ function buildTranslationServices( const intersectionObserverWithFilter = config.lazyTranslate ? new IntersectionObserverWithFilter({ filter: isTranslatableNode, - onIntersected: domNodeTranslator.translateNode, + onIntersected: domNodesTranslator.translateNode, }) : undefined; const translatorDispatcher = new TranslationDispatcher({ isTranslatableNode, - domTranslator: domNodeTranslator, + domTranslator: domNodesTranslator, lazyDOMTranslator: intersectionObserverWithFilter, }); return { - domNodeTranslator, + domNodesTranslator, translatorDispatcher, }; } @@ -77,15 +77,10 @@ describe('basic usage', () => { const parsedHTML = document.documentElement.outerHTML; // Translate document - const { translatorDispatcher, domNodeTranslator } = buildTranslationServices( - translator, - { - lazyTranslate, - }, - ); const domTranslator = new NodesTranslator({ - translatorDispatcher, - domTranslator: domNodeTranslator, + ...buildTranslationServices(translator, { + lazyTranslate, + }), }); domTranslator.observe(document.documentElement); @@ -135,11 +130,8 @@ describe('basic usage', () => { const parsedHTML = document.documentElement.outerHTML; // Translate document - const { translatorDispatcher, domNodeTranslator } = - buildTranslationServices(translator, options); const domTranslator = new NodesTranslator({ - translatorDispatcher, - domTranslator: domNodeTranslator, + ...buildTranslationServices(translator, options), }); domTranslator.observe(document.documentElement); @@ -155,11 +147,8 @@ describe('basic usage', () => { fillDocument(sample); // Translate document - const { translatorDispatcher, domNodeTranslator } = - buildTranslationServices(translator, options); const domTranslator = new NodesTranslator({ - translatorDispatcher, - domTranslator: domNodeTranslator, + ...buildTranslationServices(translator, options), }); domTranslator.observe(document.documentElement); @@ -214,11 +203,8 @@ describe('basic usage', () => { fillDocument(sample); // Translate document - const { translatorDispatcher, domNodeTranslator } = - buildTranslationServices(translator, options); const domTranslator = new NodesTranslator({ - translatorDispatcher, - domTranslator: domNodeTranslator, + ...buildTranslationServices(translator, options), }); const pElm = document.querySelector('p'); @@ -265,7 +251,7 @@ describe('basic usage', () => { fillDocument(sample); // Translate document - const { translatorDispatcher, domNodeTranslator } = + const { translatorDispatcher, domNodesTranslator } = buildTranslationServices(translator, { ...options, isTranslatableNode: configureTranslatableNodePredicate({ @@ -279,7 +265,7 @@ describe('basic usage', () => { }); const domTranslator = new NodesTranslator({ translatorDispatcher, - domTranslator: domNodeTranslator, + domNodesTranslator, }); domTranslator.observe(document.documentElement); From 57b59ab4daf2dc77696884a8a13f9d11a6ecfa8b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 6 May 2025 02:17:21 +0200 Subject: [PATCH 114/313] test: improve test case --- src/__tests__/DOMTranslator.test.ts | 34 +++++++++++++++-------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/__tests__/DOMTranslator.test.ts b/src/__tests__/DOMTranslator.test.ts index 559e80c..efd89fc 100644 --- a/src/__tests__/DOMTranslator.test.ts +++ b/src/__tests__/DOMTranslator.test.ts @@ -7,19 +7,16 @@ test('Translate and restore original node text', async () => { translateCallback: translator, }); - const originElementText = 'Hello world!'; + const nodeText = 'Hello world!'; const div = document.createElement('div'); - div.innerHTML = originElementText; + div.textContent = nodeText; domTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(div.innerHTML).toMatch(containsRegex(TRANSLATION_SYMBOL)); - - // disable translation domTranslator.restoreNode(div.childNodes[0]); - expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(div.innerHTML).toMatch(originElementText); + expect(div.textContent).toBe(nodeText); }); test('Returns original text node after translation', async () => { @@ -28,17 +25,23 @@ test('Returns original text node after translation', async () => { translateCallback: translator, }); - const originElementText = 'Hello world!'; + const nodeText = 'Hello world!'; const div = document.createElement('div'); - div.innerHTML = originElementText; + div.textContent = nodeText; + + expect(domTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); - // translate domTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); - expect(domTranslator.getOriginalNodeText(div.childNodes[0])).toEqual( - originElementText, - ); + // node has been translated + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(domTranslator.getOriginalNodeText(div.childNodes[0])).toEqual(nodeText); + + // reset translated + domTranslator.restoreNode(div.childNodes[0]); + expect(div.textContent).toBe(nodeText); + expect(domTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); }); test('Translated node has in the storage', async () => { @@ -47,14 +50,13 @@ test('Translated node has in the storage', async () => { translateCallback: translator, }); const div = document.createElement('div'); - div.innerHTML = 'Hello world!'; - + div.textContent = 'Hello world!'; domTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(domTranslator.hasNode(div.childNodes[0])).toBe(true); - //delete element domTranslator.restoreNode(div.childNodes[0]); expect(domTranslator.hasNode(div.childNodes[0])).toBe(false); }); From 4d679cd8ba168fd2412a10af745fa0d8f39cf8b3 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 6 May 2025 19:02:31 +0200 Subject: [PATCH 115/313] test: improve name --- src/__tests__/DOMTranslator.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/__tests__/DOMTranslator.test.ts b/src/__tests__/DOMTranslator.test.ts index efd89fc..4118d60 100644 --- a/src/__tests__/DOMTranslator.test.ts +++ b/src/__tests__/DOMTranslator.test.ts @@ -19,7 +19,7 @@ test('Translate and restore original node text', async () => { expect(div.textContent).toBe(nodeText); }); -test('Returns original text node after translation', async () => { +test('Returns original text node', async () => { const domTranslator = new DOMNodesTranslator({ isTranslatableNode: Boolean, translateCallback: translator, @@ -29,6 +29,7 @@ test('Returns original text node after translation', async () => { const div = document.createElement('div'); div.textContent = nodeText; + // node not translated, original text is null expect(domTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); domTranslator.translateNode(div.childNodes[0]); From 81a57118095dbcdae36c3ca21c9a28dfd4596378 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 6 May 2025 20:18:36 +0200 Subject: [PATCH 116/313] test: improve test case --- src/__tests__/DOMTranslator.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/__tests__/DOMTranslator.test.ts b/src/__tests__/DOMTranslator.test.ts index 4118d60..68c96a5 100644 --- a/src/__tests__/DOMTranslator.test.ts +++ b/src/__tests__/DOMTranslator.test.ts @@ -96,26 +96,30 @@ test('Calls updateNode when node content is updated', async () => { ]); }); -test('Restored node contain the most recent content after few translate', async () => { +test('Restored node contains the most recent content after several translations', async () => { const domTranslator = new DOMNodesTranslator({ isTranslatableNode: Boolean, translateCallback: translator, }); const div = document.createElement('div'); - div.innerHTML = 'Hello world!'; + const nodeText = 'Hello world!'; + div.textContent = nodeText; // translate domTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).toMatch(nodeText); - // translate again - const newText = 'Hello world 1234!'; - div.innerHTML = newText; + // translate again with changed text + const nodeText1 = 'My name is Jake'; + div.textContent = nodeText1; domTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).toMatch(nodeText1); // restore, elements have the last updated text and have not translated domTranslator.restoreNode(div.childNodes[0]); - expect(div.innerHTML).toMatch(newText); - expect(div.innerHTML).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).toBe(nodeText1); }); From 5ce08c885787e12c732c83ff0c7787ecea211ff5 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 6 May 2025 20:38:33 +0200 Subject: [PATCH 117/313] test: simplify mock --- .../IntersectionObserverWithFilter.test.ts | 61 +++++++------------ 1 file changed, 23 insertions(+), 38 deletions(-) diff --git a/src/__tests__/IntersectionObserverWithFilter.test.ts b/src/__tests__/IntersectionObserverWithFilter.test.ts index 73afb65..0af615e 100644 --- a/src/__tests__/IntersectionObserverWithFilter.test.ts +++ b/src/__tests__/IntersectionObserverWithFilter.test.ts @@ -10,10 +10,22 @@ const translator = vi.fn().mockImplementation(async (node: Node) => { // jsdom does not actually modify element coordinates // Create a mock that sets the real values for the coordinates // DOMRect interface requires the toJSON property, this is not necessary for our tests, so use Omit utility type -const mockBoundingClientRect = (element: HTMLElement, rect: Omit) => { +const mockBoundingClientRect = ( + element: HTMLElement, + rect: { + width: number; + height: number; + x: number; + y: number; + }, +) => { Object.defineProperty(element, 'getBoundingClientRect', { configurable: true, value: () => ({ + top: rect.y, + left: rect.x, + bottom: rect.height + rect.y, + right: rect.width + rect.x, ...rect, }), }); @@ -21,10 +33,6 @@ const mockBoundingClientRect = (element: HTMLElement, rect: Omit { mockBoundingClientRect(document.body, { - top: 0, - left: 0, - bottom: 0, - right: 0, width: 0, height: 0, x: 0, @@ -106,6 +114,7 @@ test('Not call onIntersected after node is detached', async () => { // node is detached lazyTranslator.detach(div); + await awaitTranslation(); // becomes visible and intersectable, but is still not translated after detach div.style.display = 'block'; @@ -123,10 +132,6 @@ test('Call onIntersected only after node intersect viewport', async () => { document.body.appendChild(div); mockBoundingClientRect(document.body, { - top: 0, - left: 0, - bottom: 300, - right: 300, width: 300, height: 300, x: 0, @@ -135,14 +140,10 @@ test('Call onIntersected only after node intersect viewport', async () => { // element out of viewport, it not intersect container mockBoundingClientRect(div, { - top: 400, - left: 0, - bottom: 500, - right: 100, width: 100, height: 100, x: 0, - y: 0, + y: 500, }); lazyTranslator.attach(div); @@ -154,19 +155,15 @@ test('Call onIntersected only after node intersect viewport', async () => { // change coordinates, now node in viewport mockBoundingClientRect(div, { - top: 0, - left: 0, - bottom: 200, - right: 100, width: 100, height: 100, x: 0, y: 0, }); - // simulates the scroll event; the polyfill listens for the "scroll" event in the document - // the scroll event triggers an intersection check - document.dispatchEvent(new Event('scroll', { bubbles: true })); + // simulates the scroll event; polyfill listens for the "scroll" event on the document + // The polyfill will start recalculating the element position to find intersections only after the event + document.dispatchEvent(new Event('scroll')); await awaitTranslation(); expect(translator.mock.calls).toEqual([[div.childNodes[0]]]); @@ -183,10 +180,6 @@ test('Not call a onIntersected for node that not intersect viewport after scroll document.body.appendChild(div); mockBoundingClientRect(document.body, { - top: 0, - left: 0, - bottom: 300, - right: 300, width: 300, height: 300, x: 0, @@ -195,14 +188,10 @@ test('Not call a onIntersected for node that not intersect viewport after scroll // node out of viewport, it not intersect container mockBoundingClientRect(div, { - top: 400, - left: 0, - bottom: 500, - right: 100, width: 100, height: 100, x: 0, - y: 0, + y: 400, }); lazyTranslator.attach(div); @@ -214,19 +203,15 @@ test('Not call a onIntersected for node that not intersect viewport after scroll // change coordinates, node still not in viewport mockBoundingClientRect(div, { - top: 330, - left: 0, - bottom: 200, - right: 100, width: 100, height: 100, x: 0, - y: 0, + y: 330, }); - // simulates the scroll event, and the polyfill listens for the "scroll" event in the document - // the scroll event triggers an intersection check - document.dispatchEvent(new Event('scroll', { bubbles: true })); + // simulates the scroll event; polyfill listens for the "scroll" event on the document + // The polyfill will start recalculating the element position to find intersections only after the event + document.dispatchEvent(new Event('scroll')); await awaitTranslation(); // still have not translate From c40d42bdbc9396635e5c49133c162e6f1e87d47d Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 6 May 2025 22:40:14 +0200 Subject: [PATCH 118/313] test: replace for safety dom method --- src/__tests__/IntersectionObserverWithFilter.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/__tests__/IntersectionObserverWithFilter.test.ts b/src/__tests__/IntersectionObserverWithFilter.test.ts index 0af615e..4943f4b 100644 --- a/src/__tests__/IntersectionObserverWithFilter.test.ts +++ b/src/__tests__/IntersectionObserverWithFilter.test.ts @@ -38,13 +38,13 @@ beforeEach(() => { x: 0, y: 0, }); - document.body.innerHTML = ''; + document.body.textContent = ''; vi.clearAllMocks(); }); test('Call onIntersected for node from viewport', async () => { const div = document.createElement('div'); - div.innerHTML = 'Hello, World!'; + div.textContent = 'Hello, World!'; document.body.appendChild(div); const lazyTranslator = new IntersectionObserverWithFilter({ @@ -68,7 +68,7 @@ test('Call onIntersected for a node only when it becomes intersectable', async ( // node not attach to DOM, it not intersectable, not translate it const div = document.createElement('div'); - div.innerHTML = 'Hello, world'; + div.textContent = 'Hello, world'; lazyTranslator.attach(div); await awaitTranslation(); @@ -101,7 +101,7 @@ test('Not call onIntersected after node is detached', async () => { // create node with display=none, it not intersectible const div = document.createElement('div'); - div.innerHTML = 'Hello world!'; + div.textContent = 'Hello world!'; div.style.display = 'none'; document.body.appendChild(div); @@ -128,7 +128,7 @@ test('Call onIntersected only after node intersect viewport', async () => { onIntersected: translator, }); const div = document.createElement('div'); - div.innerHTML = 'Hello world!'; + div.textContent = 'Hello world!'; document.body.appendChild(div); mockBoundingClientRect(document.body, { @@ -176,7 +176,7 @@ test('Not call a onIntersected for node that not intersect viewport after scroll onIntersected: translator, }); const div = document.createElement('div'); - div.innerHTML = 'Hello world!'; + div.textContent = 'Hello world!'; document.body.appendChild(div); mockBoundingClientRect(document.body, { From 243ad0a081ad3bb3b4df29819f0aad873e12834b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 6 May 2025 22:43:51 +0200 Subject: [PATCH 119/313] chore: improve docs --- src/utils/visitWholeTree.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/utils/visitWholeTree.ts b/src/utils/visitWholeTree.ts index e51af7c..d531daf 100644 --- a/src/utils/visitWholeTree.ts +++ b/src/utils/visitWholeTree.ts @@ -1,8 +1,7 @@ import { walkNode } from './walkNode'; /** - * Handle all translatable nodes from element - * Element, Attr, Text + * Handle all translatable nodes from elements */ export function visitWholeTree(node: Element, callback: (node: Node) => void) { From a12b292abb7b4183b61a383a69d3b12e79a86b26 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 6 May 2025 23:23:16 +0200 Subject: [PATCH 120/313] test: improve test case --- src/__tests__/TranslationDispatcher.test.ts | 54 ++++++++++++--------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 2d4d7e1..10e091d 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -5,18 +5,16 @@ import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from require('intersection-observer'); -const intersectionObserverSpy = vi.spyOn( - IntersectionObserverWithFilter.prototype, - 'attach', -); +const lazyTranslatorSpy = vi.spyOn(IntersectionObserverWithFilter.prototype, 'attach'); beforeEach(() => { vi.clearAllMocks(); + document.body.innerHTML = ''; }); const isTranslatableNode = () => true; -test('Not call intersectionObserver for not intersectedle node', async () => { +test('Do not lazily translate not-intersectable node', async () => { const domTranslator = new DOMNodesTranslator({ isTranslatableNode: isTranslatableNode, translateCallback: translator, @@ -31,19 +29,22 @@ test('Not call intersectionObserver for not intersectedle node', async () => { }), }); - // OPTION node is not intersectible, node can`t translate 'lazy' - const node = document.createElement('option'); - node.innerHTML = 'Hello, world!'; - document.body.appendChild(node); - translationDispatcher.translateNode(node); + // OPTION node is not intersectable, node can`t translate 'lazy' + const select = document.createElement('select'); + const option = document.createElement('option'); + option.textContent = 'Hello, world!'; + select.appendChild(option); + document.body.appendChild(option); + + translationDispatcher.translateNode(option); await awaitTranslation(); // lazy translator not called - expect(intersectionObserverSpy).toBeCalledTimes(0); - expect(node.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(lazyTranslatorSpy).toBeCalledTimes(0); + expect(option.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Call IntersectionObserver for deferred translation of intersecting node', async () => { +test('Lazily translate node', async () => { const domTranslator = new DOMNodesTranslator({ isTranslatableNode: isTranslatableNode, translateCallback: translator, @@ -59,17 +60,26 @@ test('Call IntersectionObserver for deferred translation of intersecting node', }); const div = document.createElement('div'); - div.innerHTML = 'Hello, world!'; + div.textContent = 'Hello, world!'; document.body.appendChild(div); translationDispatcher.translateNode(div); await awaitTranslation(); // lazy translator called - expect(intersectionObserverSpy.mock.calls).toEqual([[div]]); + expect(lazyTranslatorSpy.mock.calls).toEqual([[div]]); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Not use lazy strategy with falsy lazyTranslate param', async () => { +test('Restore translation for element', async () => { + const div = document.createElement('div'); + const text = 'Would you like a cup of tea?'; + div.textContent = text; + const div1 = document.createElement('div'); + const text1 = 'Hi! yes i would'; + div1.textContent = text1; + div.appendChild(div1); + document.body.appendChild(div); + const domTranslator = new DOMNodesTranslator({ isTranslatableNode: isTranslatableNode, translateCallback: translator, @@ -79,13 +89,13 @@ test('Not use lazy strategy with falsy lazyTranslate param', async () => { domTranslator: domTranslator, }); - const div = document.createElement('div'); - div.innerHTML = 'Hello, world!'; - document.body.appendChild(div); translationDispatcher.translateNode(div); await awaitTranslation(); - - // lazy translator not called - expect(intersectionObserverSpy.mock.calls).toEqual([]); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div1.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + + translationDispatcher.restoreNode(div); + await awaitTranslation(); + expect(div.childNodes[0].textContent).toBe(text); + expect(div1.childNodes[0].textContent).toBe(text1); }); From ceb8ddd7e1084c0ffb64eb1cbd9fb8c123e97a7d Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 6 May 2025 23:28:05 +0200 Subject: [PATCH 121/313] test: improve test case --- src/__tests__/DOMTranslator.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/__tests__/DOMTranslator.test.ts b/src/__tests__/DOMTranslator.test.ts index 68c96a5..1809362 100644 --- a/src/__tests__/DOMTranslator.test.ts +++ b/src/__tests__/DOMTranslator.test.ts @@ -39,22 +39,24 @@ test('Returns original text node', async () => { expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(domTranslator.getOriginalNodeText(div.childNodes[0])).toEqual(nodeText); - // reset translated + // reset translation domTranslator.restoreNode(div.childNodes[0]); expect(div.textContent).toBe(nodeText); expect(domTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); }); -test('Translated node has in the storage', async () => { +test('Translated node exist in the storage', async () => { const domTranslator = new DOMNodesTranslator({ isTranslatableNode: Boolean, translateCallback: translator, }); const div = document.createElement('div'); div.textContent = 'Hello world!'; + // not exists before translate + expect(domTranslator.hasNode(div.childNodes[0])).toBe(false); + domTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(domTranslator.hasNode(div.childNodes[0])).toBe(true); From 03f59d05fb6f22a75ba567e42dd71146dcf5c54f Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 6 May 2025 23:34:18 +0200 Subject: [PATCH 122/313] chore: rename --- ...tor.test.ts => DOMNodesTranslator.test.ts} | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) rename src/__tests__/{DOMTranslator.test.ts => DOMNodesTranslator.test.ts} (68%) diff --git a/src/__tests__/DOMTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts similarity index 68% rename from src/__tests__/DOMTranslator.test.ts rename to src/__tests__/DOMNodesTranslator.test.ts index 1809362..c2b67cd 100644 --- a/src/__tests__/DOMTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -2,7 +2,7 @@ import { DOMNodesTranslator } from '../DOMNodesTranslator'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; test('Translate and restore original node text', async () => { - const domTranslator = new DOMNodesTranslator({ + const domNodesTranslator = new DOMNodesTranslator({ isTranslatableNode: Boolean, translateCallback: translator, }); @@ -11,16 +11,16 @@ test('Translate and restore original node text', async () => { const div = document.createElement('div'); div.textContent = nodeText; - domTranslator.translateNode(div.childNodes[0]); + domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - domTranslator.restoreNode(div.childNodes[0]); + domNodesTranslator.restoreNode(div.childNodes[0]); expect(div.textContent).toBe(nodeText); }); test('Returns original text node', async () => { - const domTranslator = new DOMNodesTranslator({ + const domNodesTranslator = new DOMNodesTranslator({ isTranslatableNode: Boolean, translateCallback: translator, }); @@ -30,60 +30,63 @@ test('Returns original text node', async () => { div.textContent = nodeText; // node not translated, original text is null - expect(domTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); + expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); - domTranslator.translateNode(div.childNodes[0]); + domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); // node has been translated expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(domTranslator.getOriginalNodeText(div.childNodes[0])).toEqual(nodeText); + expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toEqual(nodeText); // reset translation - domTranslator.restoreNode(div.childNodes[0]); + domNodesTranslator.restoreNode(div.childNodes[0]); expect(div.textContent).toBe(nodeText); - expect(domTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); + expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); }); test('Translated node exist in the storage', async () => { - const domTranslator = new DOMNodesTranslator({ + const domNodesTranslator = new DOMNodesTranslator({ isTranslatableNode: Boolean, translateCallback: translator, }); const div = document.createElement('div'); div.textContent = 'Hello world!'; // not exists before translate - expect(domTranslator.hasNode(div.childNodes[0])).toBe(false); + expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(false); - domTranslator.translateNode(div.childNodes[0]); + domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(domTranslator.hasNode(div.childNodes[0])).toBe(true); + expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(true); - domTranslator.restoreNode(div.childNodes[0]); - expect(domTranslator.hasNode(div.childNodes[0])).toBe(false); + domNodesTranslator.restoreNode(div.childNodes[0]); + expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(false); }); test('Calls updateNode when node content is updated', async () => { - const domTranslator = new DOMNodesTranslator({ + const domNodesTranslator = new DOMNodesTranslator({ isTranslatableNode: Boolean, translateCallback: translator, }); // spy on the updateNode method - const updateNodesSpy = vi.spyOn(domTranslator as DOMNodesTranslator, 'updateNode'); + const updateNodesSpy = vi.spyOn( + domNodesTranslator as DOMNodesTranslator, + 'updateNode', + ); const text = 'Hello world!'; const div = document.createElement('div'); div.innerHTML = text; // translate element - domTranslator.translateNode(div.childNodes[0]); + domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); // In the actual code the sequence of calls is as follows: // Node is translated -> the node's content is changed -> // Node update event is triggered -> the updateNode method is called with new translated content - domTranslator.updateNode(div.childNodes[0]); + domNodesTranslator.updateNode(div.childNodes[0]); await awaitTranslation(); // update calls one time @@ -99,7 +102,7 @@ test('Calls updateNode when node content is updated', async () => { }); test('Restored node contains the most recent content after several translations', async () => { - const domTranslator = new DOMNodesTranslator({ + const domNodesTranslator = new DOMNodesTranslator({ isTranslatableNode: Boolean, translateCallback: translator, }); @@ -108,7 +111,7 @@ test('Restored node contains the most recent content after several translations' div.textContent = nodeText; // translate - domTranslator.translateNode(div.childNodes[0]); + domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(div.textContent).toMatch(nodeText); @@ -116,12 +119,12 @@ test('Restored node contains the most recent content after several translations' // translate again with changed text const nodeText1 = 'My name is Jake'; div.textContent = nodeText1; - domTranslator.translateNode(div.childNodes[0]); + domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(div.textContent).toMatch(nodeText1); // restore, elements have the last updated text and have not translated - domTranslator.restoreNode(div.childNodes[0]); + domNodesTranslator.restoreNode(div.childNodes[0]); expect(div.textContent).toBe(nodeText1); }); From d0a4a857cbf8ce5ed3e673279ddf8c35d7a408d7 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 01:12:02 +0200 Subject: [PATCH 123/313] test: improve test case --- src/__tests__/DOMNodesTranslator.test.ts | 41 ++++++++++-------------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index c2b67cd..c359a7e 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -64,41 +64,34 @@ test('Translated node exist in the storage', async () => { expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(false); }); -test('Calls updateNode when node content is updated', async () => { +test('Translate the node after updating its text', async () => { const domNodesTranslator = new DOMNodesTranslator({ isTranslatableNode: Boolean, translateCallback: translator, }); - // spy on the updateNode method - const updateNodesSpy = vi.spyOn( - domNodesTranslator as DOMNodesTranslator, - 'updateNode', - ); - const text = 'Hello world!'; - const div = document.createElement('div'); - div.innerHTML = text; + const text = 'title text'; + const div = document.createElement('a'); + div.setAttribute('title', text); // translate element - domNodesTranslator.translateNode(div.childNodes[0]); + domNodesTranslator.translateNode(div.attributes[0]); await awaitTranslation(); + expect(div.attributes[0].textContent).toMatch(text); + expect(div.attributes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - // In the actual code the sequence of calls is as follows: - // Node is translated -> the node's content is changed -> - // Node update event is triggered -> the updateNode method is called with new translated content - domNodesTranslator.updateNode(div.childNodes[0]); + // the first call updateNode will update the updateId state, but the node won’t be translated because the internal check + // (updateId <= translateContext) will return true and stop the recursion translation + domNodesTranslator.updateNode(div.attributes[0]); await awaitTranslation(); + const text1 = 'title text is update'; + div.setAttribute('title', text1); - // update calls one time - expect(updateNodesSpy).toBeCalledTimes(1); - expect(updateNodesSpy.mock.calls).toEqual([[div.childNodes[0]]]); - expect(updateNodesSpy.mock.calls).toEqual([ - [ - expect.objectContaining({ - nodeValue: expect.stringMatching(containsRegex(TRANSLATION_SYMBOL)), - }), - ], - ]); + // this call will translate node text + domNodesTranslator.updateNode(div.attributes[0]); + await awaitTranslation(); + expect(div.attributes[0].textContent).toMatch(text1); + expect(div.attributes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); test('Restored node contains the most recent content after several translations', async () => { From 24a676e6bf1645d1f7756014d8550bad01786cfe Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 01:23:15 +0200 Subject: [PATCH 124/313] chore: improve comment --- src/__tests__/DOMNodesTranslator.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index c359a7e..89040dc 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -80,8 +80,9 @@ test('Translate the node after updating its text', async () => { expect(div.attributes[0].textContent).toMatch(text); expect(div.attributes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - // the first call updateNode will update the updateId state, but the node won’t be translated because the internal check - // (updateId <= translateContext) will return true and stop the recursion translation + // the first call updateNode will update the updateId state, but the node won’t be translated + // because the internal state updateId will be equal to translateContext + // this approach prevent recursion translation domNodesTranslator.updateNode(div.attributes[0]); await awaitTranslation(); const text1 = 'title text is update'; From 0b57a8c6372d6b4529b9db71776858edb5e5a97c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 01:25:29 +0200 Subject: [PATCH 125/313] chore: remove comment, move line --- src/__tests__/IntersectionObserverWithFilter.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/__tests__/IntersectionObserverWithFilter.test.ts b/src/__tests__/IntersectionObserverWithFilter.test.ts index 4943f4b..cadf5f7 100644 --- a/src/__tests__/IntersectionObserverWithFilter.test.ts +++ b/src/__tests__/IntersectionObserverWithFilter.test.ts @@ -9,7 +9,6 @@ const translator = vi.fn().mockImplementation(async (node: Node) => { // jsdom does not actually modify element coordinates // Create a mock that sets the real values for the coordinates -// DOMRect interface requires the toJSON property, this is not necessary for our tests, so use Omit utility type const mockBoundingClientRect = ( element: HTMLElement, rect: { @@ -114,9 +113,9 @@ test('Not call onIntersected after node is detached', async () => { // node is detached lazyTranslator.detach(div); - await awaitTranslation(); // becomes visible and intersectable, but is still not translated after detach div.style.display = 'block'; + await awaitTranslation(); expect(translator.mock.calls).toEqual([]); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); From fa6895f3126b5bf20c29f77afcdd4faa576fd8b9 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 01:40:43 +0200 Subject: [PATCH 126/313] chore: rename --- src/DefaultNodesTranslator.ts | 2 +- src/TranslationDispatcher.ts | 12 +++++------ src/__tests__/NodesTranslator.test.ts | 5 ++--- src/__tests__/TranslationDispatcher.test.ts | 24 ++++++++++----------- 4 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index b026d0b..381b8a2 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -42,7 +42,7 @@ export class DefaultNodesTranslator extends NodesTranslator { super({ translatorDispatcher: new TranslationDispatcher({ isTranslatableNode: innerConfig.isTranslatableNode, - domTranslator: domNodesTranslator, + domNodesTranslator, lazyDOMTranslator, }), domNodesTranslator, diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 2988a46..f32aa62 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -16,11 +16,11 @@ export class TranslationDispatcher { constructor({ isTranslatableNode, - domTranslator: domNodesTranslator, + domNodesTranslator: domNodesTranslator, lazyDOMTranslator, }: { isTranslatableNode: TranslatableNodePredicate; - domTranslator: DOMNodesTranslator; + domNodesTranslator: DOMNodesTranslator; lazyDOMTranslator?: IntersectionObserverWithFilter; }) { this.isTranslatableNode = isTranslatableNode; @@ -81,12 +81,12 @@ export class TranslationDispatcher { visitWholeTree(node, (node) => { this.restoreNode(node, true); }); + + if (this.lazyDOMTranslator) { + this.lazyDOMTranslator.detach(node); + } } this.domNodesTranslator.restoreNode(node); - - if (this.lazyDOMTranslator && node instanceof Element) { - this.lazyDOMTranslator.detach(node); - } } } diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index e7dc732..be55af6 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -40,7 +40,7 @@ function buildTranslationServices( config.isTranslatableNode ?? configureTranslatableNodePredicate(); const domNodesTranslator = new DOMNodesTranslator({ - isTranslatableNode: isTranslatableNode, + isTranslatableNode, translateCallback, }); @@ -54,7 +54,7 @@ function buildTranslationServices( const translatorDispatcher = new TranslationDispatcher({ isTranslatableNode, - domTranslator: domNodesTranslator, + domNodesTranslator, lazyDOMTranslator: intersectionObserverWithFilter, }); @@ -119,7 +119,6 @@ describe('basic usage', () => { 'textarea', ], } satisfies NodesFilterOptions; - const options = { lazyTranslate: isLazyTranslation, isTranslatableNode: configureTranslatableNodePredicate(filterOptions), diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 10e091d..83f8d60 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -15,17 +15,16 @@ beforeEach(() => { const isTranslatableNode = () => true; test('Do not lazily translate not-intersectable node', async () => { - const domTranslator = new DOMNodesTranslator({ - isTranslatableNode: isTranslatableNode, + const domNodesTranslator = new DOMNodesTranslator({ + isTranslatableNode, translateCallback: translator, }); const translationDispatcher = new TranslationDispatcher({ isTranslatableNode, - domTranslator: domTranslator, - + domNodesTranslator, lazyDOMTranslator: new IntersectionObserverWithFilter({ filter: isTranslatableNode, - onIntersected: domTranslator.translateNode, + onIntersected: domNodesTranslator.translateNode, }), }); @@ -45,17 +44,16 @@ test('Do not lazily translate not-intersectable node', async () => { }); test('Lazily translate node', async () => { - const domTranslator = new DOMNodesTranslator({ - isTranslatableNode: isTranslatableNode, + const domNodesTranslator = new DOMNodesTranslator({ + isTranslatableNode, translateCallback: translator, }); const translationDispatcher = new TranslationDispatcher({ isTranslatableNode, - domTranslator: domTranslator, - + domNodesTranslator, lazyDOMTranslator: new IntersectionObserverWithFilter({ filter: isTranslatableNode, - onIntersected: domTranslator.translateNode, + onIntersected: domNodesTranslator.translateNode, }), }); @@ -80,13 +78,13 @@ test('Restore translation for element', async () => { div.appendChild(div1); document.body.appendChild(div); - const domTranslator = new DOMNodesTranslator({ - isTranslatableNode: isTranslatableNode, + const domNodesTranslator = new DOMNodesTranslator({ + isTranslatableNode, translateCallback: translator, }); const translationDispatcher = new TranslationDispatcher({ isTranslatableNode, - domTranslator: domTranslator, + domNodesTranslator, }); translationDispatcher.translateNode(div); From fc68b643921445aef6de1d6674f7711a53122728 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 02:06:34 +0200 Subject: [PATCH 127/313] test: improve layout --- src/__tests__/TranslationDispatcher.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 83f8d60..2d75297 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -33,13 +33,14 @@ test('Do not lazily translate not-intersectable node', async () => { const option = document.createElement('option'); option.textContent = 'Hello, world!'; select.appendChild(option); - document.body.appendChild(option); + document.body.appendChild(select); - translationDispatcher.translateNode(option); + translationDispatcher.translateNode(select); await awaitTranslation(); // lazy translator not called expect(lazyTranslatorSpy).toBeCalledTimes(0); + // translate immediately expect(option.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); From 2f4b8cbdbb403806744be4aafa602c5d5c871b0b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 02:17:44 +0200 Subject: [PATCH 128/313] chore: remove unnecessary code --- src/TranslationDispatcher.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index f32aa62..f291e7e 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -9,14 +9,14 @@ export type TranslatableNodePredicate = (node: Node) => boolean; * Class coordinates the processing of DOM nodes for translation. Choose translation strategy: lazy or immediate. */ export class TranslationDispatcher { - private readonly isTranslatableNode: TranslatableNodePredicate; - private readonly domNodesTranslator: DOMNodesTranslator; + private readonly isTranslatableNode; + private readonly domNodesTranslator; // if dependency is not passed, then the node will not be translated lazy - private readonly lazyDOMTranslator: IntersectionObserverWithFilter | null; + private readonly lazyDOMTranslator; constructor({ isTranslatableNode, - domNodesTranslator: domNodesTranslator, + domNodesTranslator, lazyDOMTranslator, }: { isTranslatableNode: TranslatableNodePredicate; From e25b85daf99be86060f6321157782d1e54bc43b9 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 02:22:48 +0200 Subject: [PATCH 129/313] chore: move to variable --- src/DefaultNodesTranslator.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 381b8a2..0896cf6 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -39,12 +39,14 @@ export class DefaultNodesTranslator extends NodesTranslator { }) : undefined; + const translatorDispatcher = new TranslationDispatcher({ + isTranslatableNode: innerConfig.isTranslatableNode, + domNodesTranslator, + lazyDOMTranslator, + }); + super({ - translatorDispatcher: new TranslationDispatcher({ - isTranslatableNode: innerConfig.isTranslatableNode, - domNodesTranslator, - lazyDOMTranslator, - }), + translatorDispatcher, domNodesTranslator, }); } From 9a34b9e32b7aff7defb50a64d7d5d825d34cd356 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 02:26:21 +0200 Subject: [PATCH 130/313] chore: remove obj --- src/DefaultNodesTranslator.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 0896cf6..53d76de 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -19,28 +19,26 @@ export interface Config { */ export class DefaultNodesTranslator extends NodesTranslator { constructor(translateCallback: TranslatorInterface, config?: Config) { - const innerConfig = { - isTranslatableNode: - config?.isTranslatableNode ?? configureTranslatableNodePredicate(), - lazyTranslate: - config?.lazyTranslate !== undefined ? config?.lazyTranslate : true, - }; + const isTranslatableNode = + config?.isTranslatableNode ?? configureTranslatableNodePredicate(); + const lazyTranslate = + config?.lazyTranslate !== undefined ? config?.lazyTranslate : true; const domNodesTranslator = new DOMNodesTranslator({ - isTranslatableNode: innerConfig.isTranslatableNode, + isTranslatableNode, translateCallback, }); // not create instance if param lazyTranslate falsy - const lazyDOMTranslator = innerConfig.lazyTranslate + const lazyDOMTranslator = lazyTranslate ? new IntersectionObserverWithFilter({ - filter: innerConfig.isTranslatableNode, + filter: isTranslatableNode, onIntersected: domNodesTranslator.translateNode, }) : undefined; const translatorDispatcher = new TranslationDispatcher({ - isTranslatableNode: innerConfig.isTranslatableNode, + isTranslatableNode, domNodesTranslator, lazyDOMTranslator, }); From bcb29d2a2a55bec489a5cc162b08c21d8165dbac Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 02:30:08 +0200 Subject: [PATCH 131/313] chore: remove unnecessary code, add comment --- src/DOMNodesTranslator.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 9a93431..410cabc 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -87,6 +87,9 @@ export class DOMNodesTranslator { return nodeData ? nodeData.originalText : null; } + /** + * Translate text-containing nodes (Text, Attr, etc) + */ public translateNode = (node: Node) => { if (this.hasNode(node)) return; @@ -112,9 +115,8 @@ export class DOMNodesTranslator { */ public restoreNode(node: Node) { const nodeData = this.nodeStorage.get(node); - if (nodeData == undefined) { - return; - } + if (nodeData == undefined) return; + // Restore original text if text been replaced if (nodeData.originalText !== null) { node.nodeValue = nodeData.originalText; @@ -127,9 +129,8 @@ export class DOMNodesTranslator { */ public updateNode(node: Node) { const nodeData = this.nodeStorage.get(node); - if (nodeData == undefined) { - return; - } + if (nodeData == undefined) return; + nodeData.updateId++; this.translateNodeContent(node); } From 10940f8a7a92d9d2cd23a1a818958312b8529883 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 02:38:57 +0200 Subject: [PATCH 132/313] chore: add comment --- src/TranslationDispatcher.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index f291e7e..161d89f 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -35,7 +35,9 @@ export class TranslationDispatcher { public hasNode(node: Node) { return this.domNodesTranslator.hasNode(node); } - + /** + * Translates nodes contained in an element (text nodes and attributes of current and inner elements) + */ public translateNode(node: Node) { // handle all nodes contained within the element (text nodes and attributes of the current and nested elements) if (node instanceof Element) { From 9aa289fca970b90bddf5ed55d992278dc739591d Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 13:17:55 +0200 Subject: [PATCH 133/313] chore: rename --- src/__tests__/DOMNodesTranslator.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 89040dc..fc8e540 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -19,7 +19,7 @@ test('Translate and restore original node text', async () => { expect(div.textContent).toBe(nodeText); }); -test('Returns original text node', async () => { +test('Stores original text on translation and clears it on restoration', async () => { const domNodesTranslator = new DOMNodesTranslator({ isTranslatableNode: Boolean, translateCallback: translator, From d34d4e77f5ef45958ae09d9a6154085073fe8e45 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 13:35:14 +0200 Subject: [PATCH 134/313] chore: improve test name, use one check style --- src/__tests__/TranslationDispatcher.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 2d75297..5ddc4d9 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -39,7 +39,7 @@ test('Do not lazily translate not-intersectable node', async () => { await awaitTranslation(); // lazy translator not called - expect(lazyTranslatorSpy).toBeCalledTimes(0); + expect(lazyTranslatorSpy.mock.calls).toEqual([]); // translate immediately expect(option.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); @@ -69,7 +69,7 @@ test('Lazily translate node', async () => { expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Restore translation for element', async () => { +test('Restores original text content of element and its children', async () => { const div = document.createElement('div'); const text = 'Would you like a cup of tea?'; div.textContent = text; From 8abd9bd2a66fa1cf55eddddd7a3b486e4ae65709 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 13:53:34 +0200 Subject: [PATCH 135/313] chore: update docs --- src/DOMNodesTranslator.ts | 2 +- src/TranslationDispatcher.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 410cabc..d4f5c7f 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -57,7 +57,7 @@ function getNodePriority(node: Node) { } /** - * Manages a translation state of DOM nodes. Processing text-containing nodes (Text, Attr, etc). + * Manages a translation state of DOM nodes. Translating text-containing nodes (Text, Attr, etc). * Registers nodes and initiates translation, updates the translation when a node is modified or deleted. */ export class DOMNodesTranslator { diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 161d89f..bd9072f 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -6,7 +6,8 @@ import { visitWholeTree } from './utils/visitWholeTree'; export type TranslatableNodePredicate = (node: Node) => boolean; /** - * Class coordinates the processing of DOM nodes for translation. Choose translation strategy: lazy or immediate. + * Class coordinates the processing of DOM nodes for translation. + * If lazyDOMTranslator is passed, it selects the translation strategy: lazy or immediate. */ export class TranslationDispatcher { private readonly isTranslatableNode; From 55e8ab9943f52c6039606ec28e674950ceb862eb Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 13:58:41 +0200 Subject: [PATCH 136/313] chore: fix typo --- src/DOMNodesTranslator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index d4f5c7f..1a36f52 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -57,7 +57,7 @@ function getNodePriority(node: Node) { } /** - * Manages a translation state of DOM nodes. Translating text-containing nodes (Text, Attr, etc). + * Manages a translation state of DOM nodes. Translates text-containing nodes (Text, Attr, etc). * Registers nodes and initiates translation, updates the translation when a node is modified or deleted. */ export class DOMNodesTranslator { From 9f43cedec03c600f8f752d4d930e1ab4862ed5e4 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 14:07:04 +0200 Subject: [PATCH 137/313] chore: rename --- src/DefaultNodesTranslator.ts | 4 ++-- src/TranslationDispatcher.ts | 18 +++++++++--------- src/__tests__/NodesTranslator.test.ts | 2 +- src/__tests__/TranslationDispatcher.test.ts | 4 ++-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 53d76de..0c438e4 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -30,7 +30,7 @@ export class DefaultNodesTranslator extends NodesTranslator { }); // not create instance if param lazyTranslate falsy - const lazyDOMTranslator = lazyTranslate + const intersectionObserverWithFilter = lazyTranslate ? new IntersectionObserverWithFilter({ filter: isTranslatableNode, onIntersected: domNodesTranslator.translateNode, @@ -40,7 +40,7 @@ export class DefaultNodesTranslator extends NodesTranslator { const translatorDispatcher = new TranslationDispatcher({ isTranslatableNode, domNodesTranslator, - lazyDOMTranslator, + intersectionObserverWithFilter, }); super({ diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index bd9072f..6303032 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -7,26 +7,26 @@ export type TranslatableNodePredicate = (node: Node) => boolean; /** * Class coordinates the processing of DOM nodes for translation. - * If lazyDOMTranslator is passed, it selects the translation strategy: lazy or immediate. + * If intersectionObserverWithFilter is passed, class selects the translation strategy: lazy or immediate. */ export class TranslationDispatcher { private readonly isTranslatableNode; private readonly domNodesTranslator; // if dependency is not passed, then the node will not be translated lazy - private readonly lazyDOMTranslator; + private readonly intersectionObserverWithFilter; constructor({ isTranslatableNode, domNodesTranslator, - lazyDOMTranslator, + intersectionObserverWithFilter, }: { isTranslatableNode: TranslatableNodePredicate; domNodesTranslator: DOMNodesTranslator; - lazyDOMTranslator?: IntersectionObserverWithFilter; + intersectionObserverWithFilter?: IntersectionObserverWithFilter; }) { this.isTranslatableNode = isTranslatableNode; this.domNodesTranslator = domNodesTranslator; - this.lazyDOMTranslator = lazyDOMTranslator || null; + this.intersectionObserverWithFilter = intersectionObserverWithFilter || null; } public updateNode(node: Node) { @@ -53,7 +53,7 @@ export class TranslationDispatcher { } // translate later or immediately - if (this.lazyDOMTranslator) { + if (this.intersectionObserverWithFilter) { // Lazy translate when own element intersect viewport // But translate at once if node have not parent (virtual node) or parent node is outside of body (utility tags like meta or title) const isAttachedToDOM = node.getRootNode() !== node; @@ -66,7 +66,7 @@ export class TranslationDispatcher { observableNode !== null && isIntersectableNode(observableNode) ) { - this.lazyDOMTranslator.attach(observableNode); + this.intersectionObserverWithFilter.attach(observableNode); return; } } @@ -85,8 +85,8 @@ export class TranslationDispatcher { this.restoreNode(node, true); }); - if (this.lazyDOMTranslator) { - this.lazyDOMTranslator.detach(node); + if (this.intersectionObserverWithFilter) { + this.intersectionObserverWithFilter.detach(node); } } diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index be55af6..0e0eace 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -55,7 +55,7 @@ function buildTranslationServices( const translatorDispatcher = new TranslationDispatcher({ isTranslatableNode, domNodesTranslator, - lazyDOMTranslator: intersectionObserverWithFilter, + intersectionObserverWithFilter, }); return { diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 5ddc4d9..80623e4 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -22,7 +22,7 @@ test('Do not lazily translate not-intersectable node', async () => { const translationDispatcher = new TranslationDispatcher({ isTranslatableNode, domNodesTranslator, - lazyDOMTranslator: new IntersectionObserverWithFilter({ + intersectionObserverWithFilter: new IntersectionObserverWithFilter({ filter: isTranslatableNode, onIntersected: domNodesTranslator.translateNode, }), @@ -52,7 +52,7 @@ test('Lazily translate node', async () => { const translationDispatcher = new TranslationDispatcher({ isTranslatableNode, domNodesTranslator, - lazyDOMTranslator: new IntersectionObserverWithFilter({ + intersectionObserverWithFilter: new IntersectionObserverWithFilter({ filter: isTranslatableNode, onIntersected: domNodesTranslator.translateNode, }), From 550ef73b272ba2c57982c3a3fccceb54c39f4002 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 15:12:34 +0200 Subject: [PATCH 138/313] chore: rename --- src/__tests__/DOMNodesTranslator.test.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index fc8e540..5e97911 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -71,28 +71,28 @@ test('Translate the node after updating its text', async () => { }); const text = 'title text'; - const div = document.createElement('a'); - div.setAttribute('title', text); + const node = document.createElement('a'); + node.setAttribute('title', text); // translate element - domNodesTranslator.translateNode(div.attributes[0]); + domNodesTranslator.translateNode(node.attributes[0]); await awaitTranslation(); - expect(div.attributes[0].textContent).toMatch(text); - expect(div.attributes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(node.attributes[0].textContent).toMatch(text); + expect(node.attributes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); // the first call updateNode will update the updateId state, but the node won’t be translated // because the internal state updateId will be equal to translateContext // this approach prevent recursion translation - domNodesTranslator.updateNode(div.attributes[0]); + domNodesTranslator.updateNode(node.attributes[0]); await awaitTranslation(); const text1 = 'title text is update'; - div.setAttribute('title', text1); + node.setAttribute('title', text1); // this call will translate node text - domNodesTranslator.updateNode(div.attributes[0]); + domNodesTranslator.updateNode(node.attributes[0]); await awaitTranslation(); - expect(div.attributes[0].textContent).toMatch(text1); - expect(div.attributes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(node.attributes[0].textContent).toMatch(text1); + expect(node.attributes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); test('Restored node contains the most recent content after several translations', async () => { From 66111f6524484984fd3ca0a2acfa9cf1baeba975 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 22:48:47 +0200 Subject: [PATCH 139/313] chore: improve description --- src/TranslationDispatcher.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 6303032..3957c5f 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -6,8 +6,8 @@ import { visitWholeTree } from './utils/visitWholeTree'; export type TranslatableNodePredicate = (node: Node) => boolean; /** - * Class coordinates the processing of DOM nodes for translation. - * If intersectionObserverWithFilter is passed, class selects the translation strategy: lazy or immediate. + * Coordinates the processing of DOM nodes for translation. + * Uses intersectionObserverWithFilter to choose between lazy and immediate translation; defaults to immediate if not provided. */ export class TranslationDispatcher { private readonly isTranslatableNode; @@ -37,7 +37,7 @@ export class TranslationDispatcher { return this.domNodesTranslator.hasNode(node); } /** - * Translates nodes contained in an element (text nodes and attributes of current and inner elements) + * Translates the given node and all its nested translatable nodes (text and attribute nodes) */ public translateNode(node: Node) { // handle all nodes contained within the element (text nodes and attributes of the current and nested elements) From 4b3620fe355a3acb4e579f50a831b17bca46df48 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 22:49:18 +0200 Subject: [PATCH 140/313] test: delete unnecessary code --- src/__tests__/DOMNodesTranslator.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 5e97911..207f508 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -70,19 +70,16 @@ test('Translate the node after updating its text', async () => { translateCallback: translator, }); - const text = 'title text'; const node = document.createElement('a'); - node.setAttribute('title', text); + node.setAttribute('title', 'title text'); // translate element domNodesTranslator.translateNode(node.attributes[0]); await awaitTranslation(); - expect(node.attributes[0].textContent).toMatch(text); expect(node.attributes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); // the first call updateNode will update the updateId state, but the node won’t be translated - // because the internal state updateId will be equal to translateContext - // this approach prevent recursion translation + // because the internal state updateId will be equal to translateContext, this approach prevent recursion translation domNodesTranslator.updateNode(node.attributes[0]); await awaitTranslation(); const text1 = 'title text is update'; @@ -108,7 +105,6 @@ test('Restored node contains the most recent content after several translations' domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(div.textContent).toMatch(nodeText); // translate again with changed text const nodeText1 = 'My name is Jake'; @@ -116,7 +112,6 @@ test('Restored node contains the most recent content after several translations' domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(div.textContent).toMatch(nodeText1); // restore, elements have the last updated text and have not translated domNodesTranslator.restoreNode(div.childNodes[0]); From 448c3b5da58ab088fd3a20bbf2b631d489f0df9d Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 7 May 2025 23:06:46 +0200 Subject: [PATCH 141/313] test: rename --- src/__tests__/TranslationDispatcher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 80623e4..1fb8698 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -69,7 +69,7 @@ test('Lazily translate node', async () => { expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Restores original text content of element and its children', async () => { +test('Translates and restores the original text content of an element and its child elements', async () => { const div = document.createElement('div'); const text = 'Would you like a cup of tea?'; div.textContent = text; From 57afdafdcd0d9b6a13c688f046ac973176c78dcd Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 13 May 2025 01:00:22 +0200 Subject: [PATCH 142/313] refactor: remove filter --- src/DefaultNodesTranslator.ts | 1 - src/IntersectionObserverWithFilter.ts | 17 ++++++++--------- src/TranslationDispatcher.ts | 10 +++++----- .../IntersectionObserverWithFilter.test.ts | 5 ----- src/__tests__/NodesTranslator.test.ts | 1 - src/__tests__/TranslationDispatcher.test.ts | 2 -- 6 files changed, 13 insertions(+), 23 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 0c438e4..7b6ebf4 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -32,7 +32,6 @@ export class DefaultNodesTranslator extends NodesTranslator { // not create instance if param lazyTranslate falsy const intersectionObserverWithFilter = lazyTranslate ? new IntersectionObserverWithFilter({ - filter: isTranslatableNode, onIntersected: domNodesTranslator.translateNode, }) : undefined; diff --git a/src/IntersectionObserverWithFilter.ts b/src/IntersectionObserverWithFilter.ts index ff5d173..1ae319a 100644 --- a/src/IntersectionObserverWithFilter.ts +++ b/src/IntersectionObserverWithFilter.ts @@ -1,5 +1,3 @@ -import { TranslatableNodePredicate } from './TranslationDispatcher'; - /** * Observe DOM nodes and call a callback for filtered nodes when they intersect the viewport */ @@ -8,21 +6,17 @@ export class IntersectionObserverWithFilter { private readonly nodesObservedForIntersection = new WeakSet(); private readonly intersectionObserver: IntersectionObserver; - private readonly filter; private readonly onIntersected; constructor({ - filter, onIntersected, config, }: { - filter: TranslatableNodePredicate; onIntersected: (node: Node) => void; config?: { intersectionConfig?: IntersectionObserverInit; }; }) { - this.filter = filter; this.onIntersected = onIntersected; this.intersectionObserver = new IntersectionObserver((entries, observer) => { @@ -43,12 +37,19 @@ export class IntersectionObserverWithFilter { }, config?.intersectionConfig); } + /** + * Starts observing the node for intersection. + * After the callback is called, the node will be removed from observation. + */ public attach(node: Element) { if (this.nodesObservedForIntersection.has(node)) return; this.nodesObservedForIntersection.add(node); this.intersectionObserver.observe(node); } + /** + * Stop observing a node. + */ public detach(node: Element) { this.nodesObservedForIntersection.delete(node); this.intersectionObserver.unobserve(node); @@ -58,9 +59,7 @@ export class IntersectionObserverWithFilter { // Translate child text nodes and attributes of target node // WARNING: we shall not touch inner nodes, because its may still not intersected node.childNodes.forEach((node) => { - if (node instanceof Element || !this.filter(node)) { - return; - } + if (node instanceof Element) return; this.onIntersected(node); }); } diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 3957c5f..73a3ebd 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -40,14 +40,14 @@ export class TranslationDispatcher { * Translates the given node and all its nested translatable nodes (text and attribute nodes) */ public translateNode(node: Node) { + // Skip not translatable nodes + if (!this.isTranslatableNode(node)) return; + // handle all nodes contained within the element (text nodes and attributes of the current and nested elements) if (node instanceof Element) { visitWholeTree(node, (node) => { if (node instanceof Element) return; - - if (this.isTranslatableNode(node)) { - this.translateNode(node); - } + this.translateNode(node); }); return; } @@ -70,7 +70,7 @@ export class TranslationDispatcher { return; } } - + // translate immediately this.domNodesTranslator.translateNode(node); } diff --git a/src/__tests__/IntersectionObserverWithFilter.test.ts b/src/__tests__/IntersectionObserverWithFilter.test.ts index cadf5f7..8be3bb1 100644 --- a/src/__tests__/IntersectionObserverWithFilter.test.ts +++ b/src/__tests__/IntersectionObserverWithFilter.test.ts @@ -47,7 +47,6 @@ test('Call onIntersected for node from viewport', async () => { document.body.appendChild(div); const lazyTranslator = new IntersectionObserverWithFilter({ - filter: Boolean, onIntersected: translator, }); @@ -61,7 +60,6 @@ test('Call onIntersected for node from viewport', async () => { test('Call onIntersected for a node only when it becomes intersectable', async () => { const lazyTranslator = new IntersectionObserverWithFilter({ - filter: Boolean, onIntersected: translator, }); @@ -94,7 +92,6 @@ test('Call onIntersected for a node only when it becomes intersectable', async ( test('Not call onIntersected after node is detached', async () => { const lazyTranslator = new IntersectionObserverWithFilter({ - filter: Boolean, onIntersected: translator, }); @@ -123,7 +120,6 @@ test('Not call onIntersected after node is detached', async () => { test('Call onIntersected only after node intersect viewport', async () => { const lazyTranslator = new IntersectionObserverWithFilter({ - filter: Boolean, onIntersected: translator, }); const div = document.createElement('div'); @@ -171,7 +167,6 @@ test('Call onIntersected only after node intersect viewport', async () => { test('Not call a onIntersected for node that not intersect viewport after scrolling', async () => { const lazyTranslator = new IntersectionObserverWithFilter({ - filter: Boolean, onIntersected: translator, }); const div = document.createElement('div'); diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 0e0eace..f97ddfb 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -47,7 +47,6 @@ function buildTranslationServices( // not create instance if param lazyTranslate falsy const intersectionObserverWithFilter = config.lazyTranslate ? new IntersectionObserverWithFilter({ - filter: isTranslatableNode, onIntersected: domNodesTranslator.translateNode, }) : undefined; diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 1fb8698..25144bd 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -23,7 +23,6 @@ test('Do not lazily translate not-intersectable node', async () => { isTranslatableNode, domNodesTranslator, intersectionObserverWithFilter: new IntersectionObserverWithFilter({ - filter: isTranslatableNode, onIntersected: domNodesTranslator.translateNode, }), }); @@ -53,7 +52,6 @@ test('Lazily translate node', async () => { isTranslatableNode, domNodesTranslator, intersectionObserverWithFilter: new IntersectionObserverWithFilter({ - filter: isTranslatableNode, onIntersected: domNodesTranslator.translateNode, }), }); From aec483ae5a12c16f012a102b64f43d732d8fb0b0 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 13 May 2025 01:05:08 +0200 Subject: [PATCH 143/313] refactor: remove filter --- src/DOMNodesTranslator.ts | 14 +---------- src/DefaultNodesTranslator.ts | 5 +--- src/__tests__/DOMNodesTranslator.test.ts | 27 ++++++--------------- src/__tests__/NodesTranslator.test.ts | 5 +--- src/__tests__/TranslationDispatcher.test.ts | 15 +++--------- 5 files changed, 13 insertions(+), 53 deletions(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 1a36f52..5c6dbdf 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -1,4 +1,3 @@ -import { TranslatableNodePredicate } from './TranslationDispatcher'; import { isInViewport } from './utils/isInViewport'; export type TranslatorInterface = (text: string, priority: number) => Promise; @@ -64,17 +63,9 @@ export class DOMNodesTranslator { private idCounter = 0; private nodeStorage = new WeakMap(); - private readonly isTranslatableNode; private readonly translateCallback; - constructor({ - isTranslatableNode, - translateCallback, - }: { - isTranslatableNode: TranslatableNodePredicate; - translateCallback: TranslatorInterface; - }) { - this.isTranslatableNode = isTranslatableNode; + constructor(translateCallback: TranslatorInterface) { this.translateCallback = translateCallback; } @@ -96,9 +87,6 @@ export class DOMNodesTranslator { // Skip empty text if (node.nodeValue === null || node.nodeValue.trim().length == 0) return; - // Skip not translatable nodes - if (!this.isTranslatableNode(node)) return; - this.nodeStorage.set(node, { id: this.idCounter++, updateId: 1, diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 7b6ebf4..3a99bff 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -24,10 +24,7 @@ export class DefaultNodesTranslator extends NodesTranslator { const lazyTranslate = config?.lazyTranslate !== undefined ? config?.lazyTranslate : true; - const domNodesTranslator = new DOMNodesTranslator({ - isTranslatableNode, - translateCallback, - }); + const domNodesTranslator = new DOMNodesTranslator(translateCallback); // not create instance if param lazyTranslate falsy const intersectionObserverWithFilter = lazyTranslate diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 207f508..2e58dd6 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -2,10 +2,7 @@ import { DOMNodesTranslator } from '../DOMNodesTranslator'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; test('Translate and restore original node text', async () => { - const domNodesTranslator = new DOMNodesTranslator({ - isTranslatableNode: Boolean, - translateCallback: translator, - }); + const domNodesTranslator = new DOMNodesTranslator(translator); const nodeText = 'Hello world!'; const div = document.createElement('div'); @@ -20,10 +17,7 @@ test('Translate and restore original node text', async () => { }); test('Stores original text on translation and clears it on restoration', async () => { - const domNodesTranslator = new DOMNodesTranslator({ - isTranslatableNode: Boolean, - translateCallback: translator, - }); + const domNodesTranslator = new DOMNodesTranslator(translator); const nodeText = 'Hello world!'; const div = document.createElement('div'); @@ -46,10 +40,8 @@ test('Stores original text on translation and clears it on restoration', async ( }); test('Translated node exist in the storage', async () => { - const domNodesTranslator = new DOMNodesTranslator({ - isTranslatableNode: Boolean, - translateCallback: translator, - }); + const domNodesTranslator = new DOMNodesTranslator(translator); + const div = document.createElement('div'); div.textContent = 'Hello world!'; // not exists before translate @@ -65,10 +57,7 @@ test('Translated node exist in the storage', async () => { }); test('Translate the node after updating its text', async () => { - const domNodesTranslator = new DOMNodesTranslator({ - isTranslatableNode: Boolean, - translateCallback: translator, - }); + const domNodesTranslator = new DOMNodesTranslator(translator); const node = document.createElement('a'); node.setAttribute('title', 'title text'); @@ -93,10 +82,8 @@ test('Translate the node after updating its text', async () => { }); test('Restored node contains the most recent content after several translations', async () => { - const domNodesTranslator = new DOMNodesTranslator({ - isTranslatableNode: Boolean, - translateCallback: translator, - }); + const domNodesTranslator = new DOMNodesTranslator(translator); + const div = document.createElement('div'); const nodeText = 'Hello world!'; div.textContent = nodeText; diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index f97ddfb..5a354fb 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -39,10 +39,7 @@ function buildTranslationServices( const isTranslatableNode = config.isTranslatableNode ?? configureTranslatableNodePredicate(); - const domNodesTranslator = new DOMNodesTranslator({ - isTranslatableNode, - translateCallback, - }); + const domNodesTranslator = new DOMNodesTranslator(translateCallback); // not create instance if param lazyTranslate falsy const intersectionObserverWithFilter = config.lazyTranslate diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 25144bd..fa03e08 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -15,10 +15,7 @@ beforeEach(() => { const isTranslatableNode = () => true; test('Do not lazily translate not-intersectable node', async () => { - const domNodesTranslator = new DOMNodesTranslator({ - isTranslatableNode, - translateCallback: translator, - }); + const domNodesTranslator = new DOMNodesTranslator(translator); const translationDispatcher = new TranslationDispatcher({ isTranslatableNode, domNodesTranslator, @@ -44,10 +41,7 @@ test('Do not lazily translate not-intersectable node', async () => { }); test('Lazily translate node', async () => { - const domNodesTranslator = new DOMNodesTranslator({ - isTranslatableNode, - translateCallback: translator, - }); + const domNodesTranslator = new DOMNodesTranslator(translator); const translationDispatcher = new TranslationDispatcher({ isTranslatableNode, domNodesTranslator, @@ -77,10 +71,7 @@ test('Translates and restores the original text content of an element and its ch div.appendChild(div1); document.body.appendChild(div); - const domNodesTranslator = new DOMNodesTranslator({ - isTranslatableNode, - translateCallback: translator, - }); + const domNodesTranslator = new DOMNodesTranslator(translator); const translationDispatcher = new TranslationDispatcher({ isTranslatableNode, domNodesTranslator, From 20aa50ea14b39246910b0a41524e67d7e4ce7dba Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 13 May 2025 01:19:42 +0200 Subject: [PATCH 144/313] chore: rename --- src/DefaultNodesTranslator.ts | 8 ++++---- ...rverWithFilter.ts => IntersectingNodeObserver.ts} | 4 ++-- src/TranslationDispatcher.ts | 4 ++-- ...lter.test.ts => IntersectingNodeObserver.test.ts} | 12 ++++++------ src/__tests__/NodesTranslator.test.ts | 8 ++++---- src/__tests__/TranslationDispatcher.test.ts | 8 ++++---- src/index.ts | 2 +- 7 files changed, 23 insertions(+), 23 deletions(-) rename src/{IntersectionObserverWithFilter.ts => IntersectingNodeObserver.ts} (92%) rename src/__tests__/{IntersectionObserverWithFilter.test.ts => IntersectingNodeObserver.test.ts} (93%) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 3a99bff..3f905aa 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -3,7 +3,7 @@ import { NodesTranslator } from './NodesTranslator'; import { configureTranslatableNodePredicate } from './utils/nodes'; import { DOMNodesTranslator, - IntersectionObserverWithFilter, + IntersectingNodeObserver, TranslatableNodePredicate, TranslationDispatcher, } from '.'; @@ -27,8 +27,8 @@ export class DefaultNodesTranslator extends NodesTranslator { const domNodesTranslator = new DOMNodesTranslator(translateCallback); // not create instance if param lazyTranslate falsy - const intersectionObserverWithFilter = lazyTranslate - ? new IntersectionObserverWithFilter({ + const intersectingNodeObserver = lazyTranslate + ? new IntersectingNodeObserver({ onIntersected: domNodesTranslator.translateNode, }) : undefined; @@ -36,7 +36,7 @@ export class DefaultNodesTranslator extends NodesTranslator { const translatorDispatcher = new TranslationDispatcher({ isTranslatableNode, domNodesTranslator, - intersectionObserverWithFilter, + intersectionObserverWithFilter: intersectingNodeObserver, }); super({ diff --git a/src/IntersectionObserverWithFilter.ts b/src/IntersectingNodeObserver.ts similarity index 92% rename from src/IntersectionObserverWithFilter.ts rename to src/IntersectingNodeObserver.ts index 1ae319a..8765159 100644 --- a/src/IntersectionObserverWithFilter.ts +++ b/src/IntersectingNodeObserver.ts @@ -1,7 +1,7 @@ /** - * Observe DOM nodes and call a callback for filtered nodes when they intersect the viewport + * Observes DOM nodes and calls a callback when they intersect with the viewport */ -export class IntersectionObserverWithFilter { +export class IntersectingNodeObserver { // Store the nodes that is under observing for intersection private readonly nodesObservedForIntersection = new WeakSet(); private readonly intersectionObserver: IntersectionObserver; diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 73a3ebd..1baad8d 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -1,5 +1,5 @@ import { DOMNodesTranslator } from './DOMNodesTranslator'; -import { IntersectionObserverWithFilter } from './IntersectionObserverWithFilter'; +import { IntersectingNodeObserver } from './IntersectingNodeObserver'; import { isIntersectableNode } from './utils/isIntersectableNode'; import { visitWholeTree } from './utils/visitWholeTree'; @@ -22,7 +22,7 @@ export class TranslationDispatcher { }: { isTranslatableNode: TranslatableNodePredicate; domNodesTranslator: DOMNodesTranslator; - intersectionObserverWithFilter?: IntersectionObserverWithFilter; + intersectionObserverWithFilter?: IntersectingNodeObserver; }) { this.isTranslatableNode = isTranslatableNode; this.domNodesTranslator = domNodesTranslator; diff --git a/src/__tests__/IntersectionObserverWithFilter.test.ts b/src/__tests__/IntersectingNodeObserver.test.ts similarity index 93% rename from src/__tests__/IntersectionObserverWithFilter.test.ts rename to src/__tests__/IntersectingNodeObserver.test.ts index 8be3bb1..fd47256 100644 --- a/src/__tests__/IntersectionObserverWithFilter.test.ts +++ b/src/__tests__/IntersectingNodeObserver.test.ts @@ -1,4 +1,4 @@ -import { IntersectionObserverWithFilter } from '../IntersectionObserverWithFilter'; +import { IntersectingNodeObserver } from '../IntersectingNodeObserver'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL } from './utils'; require('intersection-observer'); @@ -46,7 +46,7 @@ test('Call onIntersected for node from viewport', async () => { div.textContent = 'Hello, World!'; document.body.appendChild(div); - const lazyTranslator = new IntersectionObserverWithFilter({ + const lazyTranslator = new IntersectingNodeObserver({ onIntersected: translator, }); @@ -59,7 +59,7 @@ test('Call onIntersected for node from viewport', async () => { }); test('Call onIntersected for a node only when it becomes intersectable', async () => { - const lazyTranslator = new IntersectionObserverWithFilter({ + const lazyTranslator = new IntersectingNodeObserver({ onIntersected: translator, }); @@ -91,7 +91,7 @@ test('Call onIntersected for a node only when it becomes intersectable', async ( }); test('Not call onIntersected after node is detached', async () => { - const lazyTranslator = new IntersectionObserverWithFilter({ + const lazyTranslator = new IntersectingNodeObserver({ onIntersected: translator, }); @@ -119,7 +119,7 @@ test('Not call onIntersected after node is detached', async () => { }); test('Call onIntersected only after node intersect viewport', async () => { - const lazyTranslator = new IntersectionObserverWithFilter({ + const lazyTranslator = new IntersectingNodeObserver({ onIntersected: translator, }); const div = document.createElement('div'); @@ -166,7 +166,7 @@ test('Call onIntersected only after node intersect viewport', async () => { }); test('Not call a onIntersected for node that not intersect viewport after scrolling', async () => { - const lazyTranslator = new IntersectionObserverWithFilter({ + const lazyTranslator = new IntersectingNodeObserver({ onIntersected: translator, }); const div = document.createElement('div'); diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 5a354fb..852e7da 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'fs'; import { Config } from '../DefaultNodesTranslator'; import { DOMNodesTranslator, TranslatorInterface } from '../DOMNodesTranslator'; -import { IntersectionObserverWithFilter } from '../IntersectionObserverWithFilter'; +import { IntersectingNodeObserver } from '../IntersectingNodeObserver'; import { NodesTranslator } from '../NodesTranslator'; import { TranslationDispatcher } from '../TranslationDispatcher'; import { configureTranslatableNodePredicate, NodesFilterOptions } from '../utils/nodes'; @@ -42,8 +42,8 @@ function buildTranslationServices( const domNodesTranslator = new DOMNodesTranslator(translateCallback); // not create instance if param lazyTranslate falsy - const intersectionObserverWithFilter = config.lazyTranslate - ? new IntersectionObserverWithFilter({ + const intersectingNodeObserver = config.lazyTranslate + ? new IntersectingNodeObserver({ onIntersected: domNodesTranslator.translateNode, }) : undefined; @@ -51,7 +51,7 @@ function buildTranslationServices( const translatorDispatcher = new TranslationDispatcher({ isTranslatableNode, domNodesTranslator, - intersectionObserverWithFilter, + intersectionObserverWithFilter: intersectingNodeObserver, }); return { diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index fa03e08..87c2e81 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -1,11 +1,11 @@ import { DOMNodesTranslator } from '../DOMNodesTranslator'; -import { IntersectionObserverWithFilter } from '../IntersectionObserverWithFilter'; +import { IntersectingNodeObserver } from '../IntersectingNodeObserver'; import { TranslationDispatcher } from '../TranslationDispatcher'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; require('intersection-observer'); -const lazyTranslatorSpy = vi.spyOn(IntersectionObserverWithFilter.prototype, 'attach'); +const lazyTranslatorSpy = vi.spyOn(IntersectingNodeObserver.prototype, 'attach'); beforeEach(() => { vi.clearAllMocks(); @@ -19,7 +19,7 @@ test('Do not lazily translate not-intersectable node', async () => { const translationDispatcher = new TranslationDispatcher({ isTranslatableNode, domNodesTranslator, - intersectionObserverWithFilter: new IntersectionObserverWithFilter({ + intersectionObserverWithFilter: new IntersectingNodeObserver({ onIntersected: domNodesTranslator.translateNode, }), }); @@ -45,7 +45,7 @@ test('Lazily translate node', async () => { const translationDispatcher = new TranslationDispatcher({ isTranslatableNode, domNodesTranslator, - intersectionObserverWithFilter: new IntersectionObserverWithFilter({ + intersectionObserverWithFilter: new IntersectingNodeObserver({ onIntersected: domNodesTranslator.translateNode, }), }); diff --git a/src/index.ts b/src/index.ts index 51851f7..1113622 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export * from './NodesTranslator'; export * from './TranslationDispatcher'; export * from './DOMNodesTranslator'; -export * from './IntersectionObserverWithFilter'; +export * from './IntersectingNodeObserver'; export * from './DefaultNodesTranslator'; From 2a9ff99c2577a3bcba480be76ab679b4c23f5c56 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 13 May 2025 01:33:44 +0200 Subject: [PATCH 145/313] refactor: rename --- src/DefaultNodesTranslator.ts | 6 +-- src/TranslationDispatcher.ts | 44 ++++++++++----------- src/__tests__/NodesTranslator.test.ts | 6 +-- src/__tests__/TranslationDispatcher.test.ts | 16 ++++---- 4 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 3f905aa..144436c 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -34,9 +34,9 @@ export class DefaultNodesTranslator extends NodesTranslator { : undefined; const translatorDispatcher = new TranslationDispatcher({ - isTranslatableNode, - domNodesTranslator, - intersectionObserverWithFilter: intersectingNodeObserver, + filter: isTranslatableNode, + nodeTranslator: domNodesTranslator, + lazyTranslator: intersectingNodeObserver, }); super({ diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 1baad8d..e6d55db 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -7,41 +7,41 @@ export type TranslatableNodePredicate = (node: Node) => boolean; /** * Coordinates the processing of DOM nodes for translation. - * Uses intersectionObserverWithFilter to choose between lazy and immediate translation; defaults to immediate if not provided. + * Chooses between lazy and immediate translation; defaults to immediate if not provided. */ export class TranslationDispatcher { - private readonly isTranslatableNode; - private readonly domNodesTranslator; + private readonly filter; + private readonly nodeTranslator; // if dependency is not passed, then the node will not be translated lazy - private readonly intersectionObserverWithFilter; + private readonly lazyTranslator; constructor({ - isTranslatableNode, - domNodesTranslator, - intersectionObserverWithFilter, + filter, + nodeTranslator, + lazyTranslator, }: { - isTranslatableNode: TranslatableNodePredicate; - domNodesTranslator: DOMNodesTranslator; - intersectionObserverWithFilter?: IntersectingNodeObserver; + filter: TranslatableNodePredicate; + nodeTranslator: DOMNodesTranslator; + lazyTranslator?: IntersectingNodeObserver; }) { - this.isTranslatableNode = isTranslatableNode; - this.domNodesTranslator = domNodesTranslator; - this.intersectionObserverWithFilter = intersectionObserverWithFilter || null; + this.filter = filter; + this.nodeTranslator = nodeTranslator; + this.lazyTranslator = lazyTranslator || null; } public updateNode(node: Node) { - this.domNodesTranslator.updateNode(node); + this.nodeTranslator.updateNode(node); } public hasNode(node: Node) { - return this.domNodesTranslator.hasNode(node); + return this.nodeTranslator.hasNode(node); } /** * Translates the given node and all its nested translatable nodes (text and attribute nodes) */ public translateNode(node: Node) { // Skip not translatable nodes - if (!this.isTranslatableNode(node)) return; + if (!this.filter(node)) return; // handle all nodes contained within the element (text nodes and attributes of the current and nested elements) if (node instanceof Element) { @@ -53,7 +53,7 @@ export class TranslationDispatcher { } // translate later or immediately - if (this.intersectionObserverWithFilter) { + if (this.lazyTranslator) { // Lazy translate when own element intersect viewport // But translate at once if node have not parent (virtual node) or parent node is outside of body (utility tags like meta or title) const isAttachedToDOM = node.getRootNode() !== node; @@ -66,12 +66,12 @@ export class TranslationDispatcher { observableNode !== null && isIntersectableNode(observableNode) ) { - this.intersectionObserverWithFilter.attach(observableNode); + this.lazyTranslator.attach(observableNode); return; } } // translate immediately - this.domNodesTranslator.translateNode(node); + this.nodeTranslator.translateNode(node); } /** @@ -85,11 +85,11 @@ export class TranslationDispatcher { this.restoreNode(node, true); }); - if (this.intersectionObserverWithFilter) { - this.intersectionObserverWithFilter.detach(node); + if (this.lazyTranslator) { + this.lazyTranslator.detach(node); } } - this.domNodesTranslator.restoreNode(node); + this.nodeTranslator.restoreNode(node); } } diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 852e7da..96bdf52 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -49,9 +49,9 @@ function buildTranslationServices( : undefined; const translatorDispatcher = new TranslationDispatcher({ - isTranslatableNode, - domNodesTranslator, - intersectionObserverWithFilter: intersectingNodeObserver, + filter: isTranslatableNode, + nodeTranslator: domNodesTranslator, + lazyTranslator: intersectingNodeObserver, }); return { diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 87c2e81..fcf4a20 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -17,9 +17,9 @@ const isTranslatableNode = () => true; test('Do not lazily translate not-intersectable node', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const translationDispatcher = new TranslationDispatcher({ - isTranslatableNode, - domNodesTranslator, - intersectionObserverWithFilter: new IntersectingNodeObserver({ + filter: isTranslatableNode, + nodeTranslator: domNodesTranslator, + lazyTranslator: new IntersectingNodeObserver({ onIntersected: domNodesTranslator.translateNode, }), }); @@ -43,9 +43,9 @@ test('Do not lazily translate not-intersectable node', async () => { test('Lazily translate node', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const translationDispatcher = new TranslationDispatcher({ - isTranslatableNode, - domNodesTranslator, - intersectionObserverWithFilter: new IntersectingNodeObserver({ + filter: isTranslatableNode, + nodeTranslator: domNodesTranslator, + lazyTranslator: new IntersectingNodeObserver({ onIntersected: domNodesTranslator.translateNode, }), }); @@ -73,8 +73,8 @@ test('Translates and restores the original text content of an element and its ch const domNodesTranslator = new DOMNodesTranslator(translator); const translationDispatcher = new TranslationDispatcher({ - isTranslatableNode, - domNodesTranslator, + filter: isTranslatableNode, + nodeTranslator: domNodesTranslator, }); translationDispatcher.translateNode(div); From e4e1c5b82231d1d8c00ca2ea59e15ee5d6829200 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 13 May 2025 16:22:36 +0200 Subject: [PATCH 146/313] refactor: move the check down --- src/TranslationDispatcher.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index e6d55db..0263dec 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -37,20 +37,20 @@ export class TranslationDispatcher { return this.nodeTranslator.hasNode(node); } /** - * Translates the given node and all its nested translatable nodes (text and attribute nodes) + * Translates the node and all its nested translatable nodes (text and attribute nodes) */ public translateNode(node: Node) { - // Skip not translatable nodes - if (!this.filter(node)) return; - // handle all nodes contained within the element (text nodes and attributes of the current and nested elements) if (node instanceof Element) { visitWholeTree(node, (node) => { if (node instanceof Element) return; + this.translateNode(node); }); return; } + // Skip node if it does not satisfy the filter + if (!this.filter(node)) return; // translate later or immediately if (this.lazyTranslator) { From 2ee92d75b40c3954bbf8dd4a28ac95d08fe39404 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 13 May 2025 16:52:11 +0200 Subject: [PATCH 147/313] Revert "chore: rename" This reverts commit 029e9d0fa23ae951687c1e3f2ad268c97c9939ca. --- src/DefaultNodesTranslator.ts | 8 ++++---- ...Observer.ts => IntersectionObserverWithFilter.ts} | 4 ++-- src/TranslationDispatcher.ts | 4 ++-- ...est.ts => IntersectionObserverWithFilter.test.ts} | 12 ++++++------ src/__tests__/NodesTranslator.test.ts | 8 ++++---- src/__tests__/TranslationDispatcher.test.ts | 8 ++++---- src/index.ts | 2 +- 7 files changed, 23 insertions(+), 23 deletions(-) rename src/{IntersectingNodeObserver.ts => IntersectionObserverWithFilter.ts} (92%) rename src/__tests__/{IntersectingNodeObserver.test.ts => IntersectionObserverWithFilter.test.ts} (93%) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 144436c..00a5a46 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -3,7 +3,7 @@ import { NodesTranslator } from './NodesTranslator'; import { configureTranslatableNodePredicate } from './utils/nodes'; import { DOMNodesTranslator, - IntersectingNodeObserver, + IntersectionObserverWithFilter, TranslatableNodePredicate, TranslationDispatcher, } from '.'; @@ -27,8 +27,8 @@ export class DefaultNodesTranslator extends NodesTranslator { const domNodesTranslator = new DOMNodesTranslator(translateCallback); // not create instance if param lazyTranslate falsy - const intersectingNodeObserver = lazyTranslate - ? new IntersectingNodeObserver({ + const intersectionObserverWithFilter = lazyTranslate + ? new IntersectionObserverWithFilter({ onIntersected: domNodesTranslator.translateNode, }) : undefined; @@ -36,7 +36,7 @@ export class DefaultNodesTranslator extends NodesTranslator { const translatorDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: domNodesTranslator, - lazyTranslator: intersectingNodeObserver, + lazyTranslator: intersectionObserverWithFilter, }); super({ diff --git a/src/IntersectingNodeObserver.ts b/src/IntersectionObserverWithFilter.ts similarity index 92% rename from src/IntersectingNodeObserver.ts rename to src/IntersectionObserverWithFilter.ts index 8765159..1ae319a 100644 --- a/src/IntersectingNodeObserver.ts +++ b/src/IntersectionObserverWithFilter.ts @@ -1,7 +1,7 @@ /** - * Observes DOM nodes and calls a callback when they intersect with the viewport + * Observe DOM nodes and call a callback for filtered nodes when they intersect the viewport */ -export class IntersectingNodeObserver { +export class IntersectionObserverWithFilter { // Store the nodes that is under observing for intersection private readonly nodesObservedForIntersection = new WeakSet(); private readonly intersectionObserver: IntersectionObserver; diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 0263dec..78affa9 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -1,5 +1,5 @@ import { DOMNodesTranslator } from './DOMNodesTranslator'; -import { IntersectingNodeObserver } from './IntersectingNodeObserver'; +import { IntersectionObserverWithFilter } from './IntersectionObserverWithFilter'; import { isIntersectableNode } from './utils/isIntersectableNode'; import { visitWholeTree } from './utils/visitWholeTree'; @@ -22,7 +22,7 @@ export class TranslationDispatcher { }: { filter: TranslatableNodePredicate; nodeTranslator: DOMNodesTranslator; - lazyTranslator?: IntersectingNodeObserver; + lazyTranslator?: IntersectionObserverWithFilter; }) { this.filter = filter; this.nodeTranslator = nodeTranslator; diff --git a/src/__tests__/IntersectingNodeObserver.test.ts b/src/__tests__/IntersectionObserverWithFilter.test.ts similarity index 93% rename from src/__tests__/IntersectingNodeObserver.test.ts rename to src/__tests__/IntersectionObserverWithFilter.test.ts index fd47256..8be3bb1 100644 --- a/src/__tests__/IntersectingNodeObserver.test.ts +++ b/src/__tests__/IntersectionObserverWithFilter.test.ts @@ -1,4 +1,4 @@ -import { IntersectingNodeObserver } from '../IntersectingNodeObserver'; +import { IntersectionObserverWithFilter } from '../IntersectionObserverWithFilter'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL } from './utils'; require('intersection-observer'); @@ -46,7 +46,7 @@ test('Call onIntersected for node from viewport', async () => { div.textContent = 'Hello, World!'; document.body.appendChild(div); - const lazyTranslator = new IntersectingNodeObserver({ + const lazyTranslator = new IntersectionObserverWithFilter({ onIntersected: translator, }); @@ -59,7 +59,7 @@ test('Call onIntersected for node from viewport', async () => { }); test('Call onIntersected for a node only when it becomes intersectable', async () => { - const lazyTranslator = new IntersectingNodeObserver({ + const lazyTranslator = new IntersectionObserverWithFilter({ onIntersected: translator, }); @@ -91,7 +91,7 @@ test('Call onIntersected for a node only when it becomes intersectable', async ( }); test('Not call onIntersected after node is detached', async () => { - const lazyTranslator = new IntersectingNodeObserver({ + const lazyTranslator = new IntersectionObserverWithFilter({ onIntersected: translator, }); @@ -119,7 +119,7 @@ test('Not call onIntersected after node is detached', async () => { }); test('Call onIntersected only after node intersect viewport', async () => { - const lazyTranslator = new IntersectingNodeObserver({ + const lazyTranslator = new IntersectionObserverWithFilter({ onIntersected: translator, }); const div = document.createElement('div'); @@ -166,7 +166,7 @@ test('Call onIntersected only after node intersect viewport', async () => { }); test('Not call a onIntersected for node that not intersect viewport after scrolling', async () => { - const lazyTranslator = new IntersectingNodeObserver({ + const lazyTranslator = new IntersectionObserverWithFilter({ onIntersected: translator, }); const div = document.createElement('div'); diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 96bdf52..a63be77 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'fs'; import { Config } from '../DefaultNodesTranslator'; import { DOMNodesTranslator, TranslatorInterface } from '../DOMNodesTranslator'; -import { IntersectingNodeObserver } from '../IntersectingNodeObserver'; +import { IntersectionObserverWithFilter } from '../IntersectionObserverWithFilter'; import { NodesTranslator } from '../NodesTranslator'; import { TranslationDispatcher } from '../TranslationDispatcher'; import { configureTranslatableNodePredicate, NodesFilterOptions } from '../utils/nodes'; @@ -42,8 +42,8 @@ function buildTranslationServices( const domNodesTranslator = new DOMNodesTranslator(translateCallback); // not create instance if param lazyTranslate falsy - const intersectingNodeObserver = config.lazyTranslate - ? new IntersectingNodeObserver({ + const intersectionObserverWithFilter = config.lazyTranslate + ? new IntersectionObserverWithFilter({ onIntersected: domNodesTranslator.translateNode, }) : undefined; @@ -51,7 +51,7 @@ function buildTranslationServices( const translatorDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: domNodesTranslator, - lazyTranslator: intersectingNodeObserver, + lazyTranslator: intersectionObserverWithFilter, }); return { diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index fcf4a20..2521e91 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -1,11 +1,11 @@ import { DOMNodesTranslator } from '../DOMNodesTranslator'; -import { IntersectingNodeObserver } from '../IntersectingNodeObserver'; +import { IntersectionObserverWithFilter } from '../IntersectionObserverWithFilter'; import { TranslationDispatcher } from '../TranslationDispatcher'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; require('intersection-observer'); -const lazyTranslatorSpy = vi.spyOn(IntersectingNodeObserver.prototype, 'attach'); +const lazyTranslatorSpy = vi.spyOn(IntersectionObserverWithFilter.prototype, 'attach'); beforeEach(() => { vi.clearAllMocks(); @@ -19,7 +19,7 @@ test('Do not lazily translate not-intersectable node', async () => { const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: domNodesTranslator, - lazyTranslator: new IntersectingNodeObserver({ + lazyTranslator: new IntersectionObserverWithFilter({ onIntersected: domNodesTranslator.translateNode, }), }); @@ -45,7 +45,7 @@ test('Lazily translate node', async () => { const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: domNodesTranslator, - lazyTranslator: new IntersectingNodeObserver({ + lazyTranslator: new IntersectionObserverWithFilter({ onIntersected: domNodesTranslator.translateNode, }), }); diff --git a/src/index.ts b/src/index.ts index 1113622..51851f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export * from './NodesTranslator'; export * from './TranslationDispatcher'; export * from './DOMNodesTranslator'; -export * from './IntersectingNodeObserver'; +export * from './IntersectionObserverWithFilter'; export * from './DefaultNodesTranslator'; From 0cd26defc30cc1bed5eef32a27a8a4a29178483a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 13 May 2025 16:56:48 +0200 Subject: [PATCH 148/313] Revert "refactor: remove filter" This reverts commit a95d7bc05b5b2503f30e4b4fe9b26eab18e4bfdb. --- src/DefaultNodesTranslator.ts | 1 + src/IntersectionObserverWithFilter.ts | 17 +++++++++-------- .../IntersectionObserverWithFilter.test.ts | 5 +++++ src/__tests__/NodesTranslator.test.ts | 1 + src/__tests__/TranslationDispatcher.test.ts | 2 ++ 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 00a5a46..c25ef04 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -29,6 +29,7 @@ export class DefaultNodesTranslator extends NodesTranslator { // not create instance if param lazyTranslate falsy const intersectionObserverWithFilter = lazyTranslate ? new IntersectionObserverWithFilter({ + filter: isTranslatableNode, onIntersected: domNodesTranslator.translateNode, }) : undefined; diff --git a/src/IntersectionObserverWithFilter.ts b/src/IntersectionObserverWithFilter.ts index 1ae319a..ff5d173 100644 --- a/src/IntersectionObserverWithFilter.ts +++ b/src/IntersectionObserverWithFilter.ts @@ -1,3 +1,5 @@ +import { TranslatableNodePredicate } from './TranslationDispatcher'; + /** * Observe DOM nodes and call a callback for filtered nodes when they intersect the viewport */ @@ -6,17 +8,21 @@ export class IntersectionObserverWithFilter { private readonly nodesObservedForIntersection = new WeakSet(); private readonly intersectionObserver: IntersectionObserver; + private readonly filter; private readonly onIntersected; constructor({ + filter, onIntersected, config, }: { + filter: TranslatableNodePredicate; onIntersected: (node: Node) => void; config?: { intersectionConfig?: IntersectionObserverInit; }; }) { + this.filter = filter; this.onIntersected = onIntersected; this.intersectionObserver = new IntersectionObserver((entries, observer) => { @@ -37,19 +43,12 @@ export class IntersectionObserverWithFilter { }, config?.intersectionConfig); } - /** - * Starts observing the node for intersection. - * After the callback is called, the node will be removed from observation. - */ public attach(node: Element) { if (this.nodesObservedForIntersection.has(node)) return; this.nodesObservedForIntersection.add(node); this.intersectionObserver.observe(node); } - /** - * Stop observing a node. - */ public detach(node: Element) { this.nodesObservedForIntersection.delete(node); this.intersectionObserver.unobserve(node); @@ -59,7 +58,9 @@ export class IntersectionObserverWithFilter { // Translate child text nodes and attributes of target node // WARNING: we shall not touch inner nodes, because its may still not intersected node.childNodes.forEach((node) => { - if (node instanceof Element) return; + if (node instanceof Element || !this.filter(node)) { + return; + } this.onIntersected(node); }); } diff --git a/src/__tests__/IntersectionObserverWithFilter.test.ts b/src/__tests__/IntersectionObserverWithFilter.test.ts index 8be3bb1..cadf5f7 100644 --- a/src/__tests__/IntersectionObserverWithFilter.test.ts +++ b/src/__tests__/IntersectionObserverWithFilter.test.ts @@ -47,6 +47,7 @@ test('Call onIntersected for node from viewport', async () => { document.body.appendChild(div); const lazyTranslator = new IntersectionObserverWithFilter({ + filter: Boolean, onIntersected: translator, }); @@ -60,6 +61,7 @@ test('Call onIntersected for node from viewport', async () => { test('Call onIntersected for a node only when it becomes intersectable', async () => { const lazyTranslator = new IntersectionObserverWithFilter({ + filter: Boolean, onIntersected: translator, }); @@ -92,6 +94,7 @@ test('Call onIntersected for a node only when it becomes intersectable', async ( test('Not call onIntersected after node is detached', async () => { const lazyTranslator = new IntersectionObserverWithFilter({ + filter: Boolean, onIntersected: translator, }); @@ -120,6 +123,7 @@ test('Not call onIntersected after node is detached', async () => { test('Call onIntersected only after node intersect viewport', async () => { const lazyTranslator = new IntersectionObserverWithFilter({ + filter: Boolean, onIntersected: translator, }); const div = document.createElement('div'); @@ -167,6 +171,7 @@ test('Call onIntersected only after node intersect viewport', async () => { test('Not call a onIntersected for node that not intersect viewport after scrolling', async () => { const lazyTranslator = new IntersectionObserverWithFilter({ + filter: Boolean, onIntersected: translator, }); const div = document.createElement('div'); diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index a63be77..ae5f25c 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -44,6 +44,7 @@ function buildTranslationServices( // not create instance if param lazyTranslate falsy const intersectionObserverWithFilter = config.lazyTranslate ? new IntersectionObserverWithFilter({ + filter: isTranslatableNode, onIntersected: domNodesTranslator.translateNode, }) : undefined; diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 2521e91..ddca71d 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -20,6 +20,7 @@ test('Do not lazily translate not-intersectable node', async () => { filter: isTranslatableNode, nodeTranslator: domNodesTranslator, lazyTranslator: new IntersectionObserverWithFilter({ + filter: isTranslatableNode, onIntersected: domNodesTranslator.translateNode, }), }); @@ -46,6 +47,7 @@ test('Lazily translate node', async () => { filter: isTranslatableNode, nodeTranslator: domNodesTranslator, lazyTranslator: new IntersectionObserverWithFilter({ + filter: isTranslatableNode, onIntersected: domNodesTranslator.translateNode, }), }); From 6620af5ba02282512cd732abba4bce8c55135902 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 13 May 2025 18:15:56 +0200 Subject: [PATCH 149/313] test: add test case --- src/__tests__/TranslationDispatcher.test.ts | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index ddca71d..f269479 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -1,6 +1,7 @@ import { DOMNodesTranslator } from '../DOMNodesTranslator'; import { IntersectionObserverWithFilter } from '../IntersectionObserverWithFilter'; import { TranslationDispatcher } from '../TranslationDispatcher'; +import { configureTranslatableNodePredicate } from '../utils/nodes'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; require('intersection-observer'); @@ -89,3 +90,36 @@ test('Translates and restores the original text content of an element and its ch expect(div.childNodes[0].textContent).toBe(text); expect(div1.childNodes[0].textContent).toBe(text1); }); + +test('IntersectionObserver does not translate ignored node inside target', async () => { + const isTranslatableNode = configureTranslatableNodePredicate({ + ignoredSelectors: ['comment'], + }); + + const domNodesTranslator = new DOMNodesTranslator(translator); + const translationDispatcher = new TranslationDispatcher({ + filter: isTranslatableNode, + nodeTranslator: domNodesTranslator, + lazyTranslator: new IntersectionObserverWithFilter({ + filter: isTranslatableNode, + onIntersected: domNodesTranslator.translateNode, + }), + }); + + const div = document.createElement('div'); + div.textContent = 'I`m block i have four corners'; + // Comment element should not be translated + const comment = document.createComment('I`m comment node, not translate me please'); + div.appendChild(comment); + document.body.appendChild(div); + + // IntersectionObserverWithFilter receives a div element containing a comment that should not be translated + translationDispatcher.translateNode(div); + await awaitTranslation(); + + // call IntersectionObserverWithFilter with element + expect(lazyTranslatorSpy.mock.calls).toEqual([[div]]); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + // comment not translated + expect(div.childNodes[1].textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); +}); From 370b07eb392e3e8e9f57abdd064a350a2b2203c8 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 13 May 2025 18:18:50 +0200 Subject: [PATCH 150/313] chore: improve docs and style --- src/IntersectionObserverWithFilter.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/IntersectionObserverWithFilter.ts b/src/IntersectionObserverWithFilter.ts index ff5d173..25fc4fd 100644 --- a/src/IntersectionObserverWithFilter.ts +++ b/src/IntersectionObserverWithFilter.ts @@ -1,7 +1,7 @@ import { TranslatableNodePredicate } from './TranslationDispatcher'; /** - * Observe DOM nodes and call a callback for filtered nodes when they intersect the viewport + * Observes DOM elements and calls a callback for filtered child nodes when they intersect the viewport. */ export class IntersectionObserverWithFilter { // Store the nodes that is under observing for intersection @@ -37,30 +37,38 @@ export class IntersectionObserverWithFilter { // This makes it possible to observe the node again later if needed this.nodesObservedForIntersection.delete(node); observer.unobserve(node); - this.handlerIntersectNode(node); }); }, config?.intersectionConfig); } + /** + * Starts observing the element for intersection. + * The element will be automatically removed from observation after the call onIntersected callback. + */ public attach(node: Element) { if (this.nodesObservedForIntersection.has(node)) return; this.nodesObservedForIntersection.add(node); this.intersectionObserver.observe(node); } + /** + * Stops observing the element. It is removes from observation. + */ public detach(node: Element) { this.nodesObservedForIntersection.delete(node); this.intersectionObserver.unobserve(node); } - private handlerIntersectNode(node: Node) { + /** + * The element may contain nodes that are not translatable. + * These should be filtered before calls onIntersected. + */ + private handlerIntersectNode(node: Element) { // Translate child text nodes and attributes of target node // WARNING: we shall not touch inner nodes, because its may still not intersected node.childNodes.forEach((node) => { - if (node instanceof Element || !this.filter(node)) { - return; - } + if (node instanceof Element || !this.filter(node)) return; this.onIntersected(node); }); } From fabb9a7265b9cb6eb164dd4c2dcaf5b3b224e0f4 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 13 May 2025 23:08:06 +0200 Subject: [PATCH 151/313] chore: improve docs and style --- src/DOMNodesTranslator.ts | 6 +----- src/IntersectionObserverWithFilter.ts | 5 +++-- src/TranslationDispatcher.ts | 7 +++---- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 5c6dbdf..0604e16 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -63,11 +63,7 @@ export class DOMNodesTranslator { private idCounter = 0; private nodeStorage = new WeakMap(); - private readonly translateCallback; - - constructor(translateCallback: TranslatorInterface) { - this.translateCallback = translateCallback; - } + constructor(private readonly translateCallback: TranslatorInterface) {} public hasNode(node: Node) { return this.nodeStorage.has(node); diff --git a/src/IntersectionObserverWithFilter.ts b/src/IntersectionObserverWithFilter.ts index 25fc4fd..c6ece4b 100644 --- a/src/IntersectionObserverWithFilter.ts +++ b/src/IntersectionObserverWithFilter.ts @@ -44,7 +44,8 @@ export class IntersectionObserverWithFilter { /** * Starts observing the element for intersection. - * The element will be automatically removed from observation after the call onIntersected callback. + * When the element intersects the viewport, the `onIntersected` callback is invoked, + * and the element is automatically removed from observation. */ public attach(node: Element) { if (this.nodesObservedForIntersection.has(node)) return; @@ -61,7 +62,7 @@ export class IntersectionObserverWithFilter { } /** - * The element may contain nodes that are not translatable. + * The element may contain nodes that are should not to translate. * These should be filtered before calls onIntersected. */ private handlerIntersectNode(node: Element) { diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 78affa9..a4cda9f 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -40,19 +40,18 @@ export class TranslationDispatcher { * Translates the node and all its nested translatable nodes (text and attribute nodes) */ public translateNode(node: Node) { - // handle all nodes contained within the element (text nodes and attributes of the current and nested elements) + // Translate all nodes which element contains (text nodes and attributes of current and inner elements) if (node instanceof Element) { visitWholeTree(node, (node) => { if (node instanceof Element) return; - this.translateNode(node); }); return; } + // Handle text nodes and attributes + // Skip node if it does not satisfy the filter if (!this.filter(node)) return; - - // translate later or immediately if (this.lazyTranslator) { // Lazy translate when own element intersect viewport // But translate at once if node have not parent (virtual node) or parent node is outside of body (utility tags like meta or title) From 64dec827a8479b095627b74c8893a87365e45994 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 13 May 2025 23:37:29 +0200 Subject: [PATCH 152/313] chore: improve docs and names --- src/IntersectionObserverWithFilter.ts | 5 ++--- src/TranslationDispatcher.ts | 2 +- src/__tests__/TranslationDispatcher.test.ts | 5 ++--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/IntersectionObserverWithFilter.ts b/src/IntersectionObserverWithFilter.ts index c6ece4b..e022156 100644 --- a/src/IntersectionObserverWithFilter.ts +++ b/src/IntersectionObserverWithFilter.ts @@ -62,12 +62,11 @@ export class IntersectionObserverWithFilter { } /** - * The element may contain nodes that are should not to translate. - * These should be filtered before calls onIntersected. + * Translate child text nodes and attributes of target node */ private handlerIntersectNode(node: Element) { - // Translate child text nodes and attributes of target node // WARNING: we shall not touch inner nodes, because its may still not intersected + // The element may contain nodes that are should not to translate. Filtered before calls onIntersected. node.childNodes.forEach((node) => { if (node instanceof Element || !this.filter(node)) return; this.onIntersected(node); diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index a4cda9f..a1fc17b 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -6,7 +6,7 @@ import { visitWholeTree } from './utils/visitWholeTree'; export type TranslatableNodePredicate = (node: Node) => boolean; /** - * Coordinates the processing of DOM nodes for translation. + * Coordinates the DOM nodes translation process. * Chooses between lazy and immediate translation; defaults to immediate if not provided. */ export class TranslationDispatcher { diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index f269479..4dfc456 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -42,7 +42,7 @@ test('Do not lazily translate not-intersectable node', async () => { expect(option.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Lazily translate node', async () => { +test('Lazily translate intersectable node', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, @@ -91,7 +91,7 @@ test('Translates and restores the original text content of an element and its ch expect(div1.childNodes[0].textContent).toBe(text1); }); -test('IntersectionObserver does not translate ignored node inside target', async () => { +test('Do not translated ignored comment node inside observed element', async () => { const isTranslatableNode = configureTranslatableNodePredicate({ ignoredSelectors: ['comment'], }); @@ -108,7 +108,6 @@ test('IntersectionObserver does not translate ignored node inside target', async const div = document.createElement('div'); div.textContent = 'I`m block i have four corners'; - // Comment element should not be translated const comment = document.createComment('I`m comment node, not translate me please'); div.appendChild(comment); document.body.appendChild(div); From 657cb3a3836707d517e50c50806f3df73d2cb3ec Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 13 May 2025 23:57:34 +0200 Subject: [PATCH 153/313] test: check text on the element itself --- src/__tests__/TranslationDispatcher.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 4dfc456..1adfe99 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -82,8 +82,8 @@ test('Translates and restores the original text content of an element and its ch translationDispatcher.translateNode(div); await awaitTranslation(); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(div1.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div1.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); translationDispatcher.restoreNode(div); await awaitTranslation(); From 16bc337f2fddc0d44665e005d52cb8d44b6455cb Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 14 May 2025 00:25:17 +0200 Subject: [PATCH 154/313] test: ensure to correct result --- src/__tests__/DOMNodesTranslator.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 2e58dd6..ee6c0eb 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -43,7 +43,8 @@ test('Translated node exist in the storage', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const div = document.createElement('div'); - div.textContent = 'Hello world!'; + const nodeText = 'Hello world!'; + div.textContent = nodeText; // not exists before translate expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(false); @@ -53,6 +54,7 @@ test('Translated node exist in the storage', async () => { expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(true); domNodesTranslator.restoreNode(div.childNodes[0]); + expect(div.textContent).toBe(nodeText); expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(false); }); From 9abd181c7524fd2bce4f2192abece9c98dbbe602 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 14 May 2025 00:36:49 +0200 Subject: [PATCH 155/313] refactor: use strict equality --- src/DOMNodesTranslator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 0604e16..3e0aba8 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -99,7 +99,7 @@ export class DOMNodesTranslator { */ public restoreNode(node: Node) { const nodeData = this.nodeStorage.get(node); - if (nodeData == undefined) return; + if (!nodeData) return; // Restore original text if text been replaced if (nodeData.originalText !== null) { @@ -113,7 +113,7 @@ export class DOMNodesTranslator { */ public updateNode(node: Node) { const nodeData = this.nodeStorage.get(node); - if (nodeData == undefined) return; + if (!nodeData) return; nodeData.updateId++; this.translateNodeContent(node); From c7ee9b1472132c6906286ec5c8c7e7c74b0f266e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 14 May 2025 00:57:22 +0200 Subject: [PATCH 156/313] test: improve --- src/__tests__/TranslationDispatcher.test.ts | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 1adfe99..e642339 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -15,7 +15,7 @@ beforeEach(() => { const isTranslatableNode = () => true; -test('Do not lazily translate not-intersectable node', async () => { +test('Translate immediately if node is not eligible for lazy translation', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, @@ -59,12 +59,12 @@ test('Lazily translate intersectable node', async () => { translationDispatcher.translateNode(div); await awaitTranslation(); - // lazy translator called + // lazy translator called and translated element expect(lazyTranslatorSpy.mock.calls).toEqual([[div]]); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Translates and restores the original text content of an element and its child elements', async () => { +test('Translates and restores the element and its child elements', async () => { const div = document.createElement('div'); const text = 'Would you like a cup of tea?'; div.textContent = text; @@ -82,6 +82,7 @@ test('Translates and restores the original text content of an element and its ch translationDispatcher.translateNode(div); await awaitTranslation(); + // check text on the element itself expect(div.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(div1.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); @@ -91,18 +92,17 @@ test('Translates and restores the original text content of an element and its ch expect(div1.childNodes[0].textContent).toBe(text1); }); -test('Do not translated ignored comment node inside observed element', async () => { - const isTranslatableNode = configureTranslatableNodePredicate({ +test('Do not translate ignored node inside element during lazyTranslation', async () => { + const filter = configureTranslatableNodePredicate({ ignoredSelectors: ['comment'], }); - - const domNodesTranslator = new DOMNodesTranslator(translator); + const nodeTranslator = new DOMNodesTranslator(translator); const translationDispatcher = new TranslationDispatcher({ - filter: isTranslatableNode, - nodeTranslator: domNodesTranslator, + filter, + nodeTranslator, lazyTranslator: new IntersectionObserverWithFilter({ - filter: isTranslatableNode, - onIntersected: domNodesTranslator.translateNode, + filter, + onIntersected: nodeTranslator.translateNode, }), }); @@ -120,5 +120,5 @@ test('Do not translated ignored comment node inside observed element', async () expect(lazyTranslatorSpy.mock.calls).toEqual([[div]]); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); // comment not translated - expect(div.childNodes[1].textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(comment.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); From 4ea7c49647640466d247300f30f8fd07fc03714d Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 14 May 2025 01:29:54 +0200 Subject: [PATCH 157/313] refactor: rename --- src/DefaultNodesTranslator.ts | 2 +- src/NodesTranslator.ts | 24 ++++++++++++------------ src/__tests__/NodesTranslator.test.ts | 12 +++++++----- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index c25ef04..a4bb0f1 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -41,7 +41,7 @@ export class DefaultNodesTranslator extends NodesTranslator { }); super({ - translatorDispatcher, + dispatcher: translatorDispatcher, domNodesTranslator, }); } diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 0eff65d..c0a752e 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -10,17 +10,17 @@ import { TranslationDispatcher } from './TranslationDispatcher'; * Module for dynamic translate a DOM nodes */ export class NodesTranslator { - private readonly translatorDispatcher; + private readonly dispatcher; private readonly domNodesTranslator; constructor({ - translatorDispatcher, + dispatcher, domNodesTranslator, }: { - translatorDispatcher: TranslationDispatcher; + dispatcher: TranslationDispatcher; domNodesTranslator: DOMNodesTranslator; }) { - this.translatorDispatcher = translatorDispatcher; + this.dispatcher = dispatcher; this.domNodesTranslator = domNodesTranslator; } @@ -35,13 +35,13 @@ export class NodesTranslator { this.observedNodesStorage.set(node, observer); observer.addHandler('elementAdded', ({ target }) => - this.translatorDispatcher.translateNode(target), + this.dispatcher.translateNode(target), ); observer.addHandler('elementRemoved', ({ target }) => - this.translatorDispatcher.restoreNode(target), + this.dispatcher.restoreNode(target), ); observer.addHandler('characterData', ({ target }) => { - this.translatorDispatcher.updateNode(target); + this.dispatcher.updateNode(target); }); observer.addHandler('changeAttribute', ({ target, attributeName }) => { if (attributeName === undefined || attributeName === null) return; @@ -52,15 +52,15 @@ export class NodesTranslator { if (attribute === null) return; // NOTE: If need delete untracked nodes, we should keep relates like Element -> attributes - if (!this.translatorDispatcher.hasNode(attribute)) { - this.translatorDispatcher.translateNode(attribute); + if (!this.dispatcher.hasNode(attribute)) { + this.dispatcher.translateNode(attribute); } else { - this.translatorDispatcher.updateNode(attribute); + this.dispatcher.updateNode(attribute); } }); observer.observe(node); - this.translatorDispatcher.translateNode(node); + this.dispatcher.translateNode(node); } public unobserve(node: Element) { @@ -68,7 +68,7 @@ export class NodesTranslator { throw new Error('Node is not under observe'); } - this.translatorDispatcher.restoreNode(node); + this.dispatcher.restoreNode(node); this.observedNodesStorage.get(node)?.disconnect(); this.observedNodesStorage.delete(node); } diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index ae5f25c..01a9d9a 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -57,7 +57,7 @@ function buildTranslationServices( return { domNodesTranslator, - translatorDispatcher, + dispatcher: translatorDispatcher, }; } @@ -247,8 +247,9 @@ describe('basic usage', () => { fillDocument(sample); // Translate document - const { translatorDispatcher, domNodesTranslator } = - buildTranslationServices(translator, { + const { dispatcher, domNodesTranslator } = buildTranslationServices( + translator, + { ...options, isTranslatableNode: configureTranslatableNodePredicate({ ...filterOptions, @@ -258,9 +259,10 @@ describe('basic usage', () => { '.custom-elements :checked', ], }), - }); + }, + ); const domTranslator = new NodesTranslator({ - translatorDispatcher, + dispatcher, domNodesTranslator, }); domTranslator.observe(document.documentElement); From 4590fc18f79ce4fabec069e090564b4874a75698 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 14 May 2025 01:45:24 +0200 Subject: [PATCH 158/313] refactor: rename --- src/DefaultNodesTranslator.ts | 2 +- src/NodesTranslator.ts | 12 ++++++------ src/__tests__/NodesTranslator.test.ts | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index a4bb0f1..865835a 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -42,7 +42,7 @@ export class DefaultNodesTranslator extends NodesTranslator { super({ dispatcher: translatorDispatcher, - domNodesTranslator, + nodeTranslator: domNodesTranslator, }); } } diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index c0a752e..a72afa9 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,6 +1,6 @@ -import { DOMNodesTranslator } from './DOMNodesTranslator'; import { XMutationObserver } from './lib/XMutationObserver'; import { TranslationDispatcher } from './TranslationDispatcher'; +import { DOMNodesTranslator } from '.'; // TODO: consider local language definitions (and implement `from`, `to` parameters for translator to specify default or locale languages) // TODO: scan nodes lazy - defer scan to `requestIdleCallback` instead of instant scan @@ -11,17 +11,17 @@ import { TranslationDispatcher } from './TranslationDispatcher'; */ export class NodesTranslator { private readonly dispatcher; - private readonly domNodesTranslator; + private readonly nodeTranslator; constructor({ dispatcher, - domNodesTranslator, + nodeTranslator, }: { dispatcher: TranslationDispatcher; - domNodesTranslator: DOMNodesTranslator; + nodeTranslator: DOMNodesTranslator; }) { this.dispatcher = dispatcher; - this.domNodesTranslator = domNodesTranslator; + this.nodeTranslator = nodeTranslator; } private readonly observedNodesStorage = new Map(); @@ -74,6 +74,6 @@ export class NodesTranslator { } public getNodeData(node: Node) { - return this.domNodesTranslator.getOriginalNodeText(node); + return this.nodeTranslator.getOriginalNodeText(node); } } diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 01a9d9a..9c7ee76 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -56,7 +56,7 @@ function buildTranslationServices( }); return { - domNodesTranslator, + nodeTranslator: domNodesTranslator, dispatcher: translatorDispatcher, }; } @@ -247,7 +247,7 @@ describe('basic usage', () => { fillDocument(sample); // Translate document - const { dispatcher, domNodesTranslator } = buildTranslationServices( + const { dispatcher, nodeTranslator } = buildTranslationServices( translator, { ...options, @@ -263,7 +263,7 @@ describe('basic usage', () => { ); const domTranslator = new NodesTranslator({ dispatcher, - domNodesTranslator, + nodeTranslator, }); domTranslator.observe(document.documentElement); From 74e325847b33e99148677ab631cb92684a8a6db2 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 16 May 2025 00:58:51 +0200 Subject: [PATCH 159/313] refactor: filter first --- src/TranslationDispatcher.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index a1fc17b..5313658 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -40,6 +40,9 @@ export class TranslationDispatcher { * Translates the node and all its nested translatable nodes (text and attribute nodes) */ public translateNode(node: Node) { + // Skip node if it does not satisfy the filter + if (!this.filter(node)) return; + // Translate all nodes which element contains (text nodes and attributes of current and inner elements) if (node instanceof Element) { visitWholeTree(node, (node) => { @@ -48,10 +51,9 @@ export class TranslationDispatcher { }); return; } + // Handle text nodes and attributes - // Skip node if it does not satisfy the filter - if (!this.filter(node)) return; if (this.lazyTranslator) { // Lazy translate when own element intersect viewport // But translate at once if node have not parent (virtual node) or parent node is outside of body (utility tags like meta or title) From 1b6bf1f62bb95f0700328c894215ab67f0298b9a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 17 May 2025 16:28:58 +0200 Subject: [PATCH 160/313] refactor: receive node instead element for observing intersection --- src/DefaultNodesTranslator.ts | 5 +- src/IntersectionObserverWithFilter.ts | 102 +++++++++++------- src/TranslationDispatcher.ts | 18 +--- .../IntersectionObserverWithFilter.test.ts | 61 ++++------- src/__tests__/NodesTranslator.test.ts | 5 +- src/__tests__/TranslationDispatcher.test.ts | 24 ++--- 6 files changed, 97 insertions(+), 118 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 865835a..0f49289 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -28,10 +28,7 @@ export class DefaultNodesTranslator extends NodesTranslator { // not create instance if param lazyTranslate falsy const intersectionObserverWithFilter = lazyTranslate - ? new IntersectionObserverWithFilter({ - filter: isTranslatableNode, - onIntersected: domNodesTranslator.translateNode, - }) + ? new IntersectionObserverWithFilter() : undefined; const translatorDispatcher = new TranslationDispatcher({ diff --git a/src/IntersectionObserverWithFilter.ts b/src/IntersectionObserverWithFilter.ts index e022156..0a326e4 100644 --- a/src/IntersectionObserverWithFilter.ts +++ b/src/IntersectionObserverWithFilter.ts @@ -1,30 +1,25 @@ -import { TranslatableNodePredicate } from './TranslationDispatcher'; +import { isIntersectableNode } from './utils/isIntersectableNode'; + +/** + * @returns Returns the node owner element. + */ +export function getElementOwnedNode(node: Node) { + return node instanceof Attr ? node.ownerElement : node.parentElement; +} /** * Observes DOM elements and calls a callback for filtered child nodes when they intersect the viewport. */ export class IntersectionObserverWithFilter { - // Store the nodes that is under observing for intersection - private readonly nodesObservedForIntersection = new WeakSet(); private readonly intersectionObserver: IntersectionObserver; - private readonly filter; - private readonly onIntersected; - - constructor({ - filter, - onIntersected, - config, - }: { - filter: TranslatableNodePredicate; - onIntersected: (node: Node) => void; - config?: { - intersectionConfig?: IntersectionObserverInit; - }; - }) { - this.filter = filter; - this.onIntersected = onIntersected; + // Store the nodes and his parent element that is under observing for intersection + private readonly nodesObservedForIntersection = new WeakMap< + Element, + { node: Node; callback: (node: Node) => void }[] + >(); + constructor(intersectionConfig?: IntersectionObserverInit) { this.intersectionObserver = new IntersectionObserver((entries, observer) => { entries.forEach((entry) => { const node = entry.target; @@ -33,43 +28,70 @@ export class IntersectionObserverWithFilter { if (!this.nodesObservedForIntersection.has(node) || !entry.isIntersecting) return; - // Process the node once and forget it - // This makes it possible to observe the node again later if needed + this.triggerChildTextNodes(node); + + // Process the element once and forget it + // This makes it possible to observe the element again later if needed this.nodesObservedForIntersection.delete(node); observer.unobserve(node); - this.handlerIntersectNode(node); }); - }, config?.intersectionConfig); + }, intersectionConfig); } /** - * Starts observing the element for intersection. - * When the element intersects the viewport, the `onIntersected` callback is invoked, - * and the element is automatically removed from observation. + * Starts observing the node for intersection. + * When the element that owns the node intersects the viewport, the callback is invoked. + * Then the owner element and all its tracked nodes are automatically removed from observation. */ - public attach(node: Element) { - if (this.nodesObservedForIntersection.has(node)) return; - this.nodesObservedForIntersection.add(node); - this.intersectionObserver.observe(node); + public attach(node: Node, callback: (node: Node) => void) { + const ownerElement = getElementOwnedNode(node); + + // if node have not parent (virtual node) or not intersecteble calls callback immediately + if (!ownerElement || !isIntersectableNode(ownerElement)) { + callback(node); + return; + } + + // add observableNode if not exist + const observedNodes = this.nodesObservedForIntersection.get(ownerElement); + const entry = { node, callback }; + if (observedNodes) { + observedNodes?.push(entry); + } else { + this.nodesObservedForIntersection.set(ownerElement, [entry]); + } + + // start observe element for intersection + this.intersectionObserver.observe(ownerElement); } /** - * Stops observing the element. It is removes from observation. + * Stops observing the node. It is removes from observation. */ - public detach(node: Element) { - this.nodesObservedForIntersection.delete(node); - this.intersectionObserver.unobserve(node); + public detach(node: Node) { + const ownerElement = getElementOwnedNode(node); + if (!ownerElement) return; + + const observedNodes = this.nodesObservedForIntersection.get(ownerElement); + + const filtered = observedNodes?.filter((entry) => entry.node !== node); + + if (filtered && filtered?.length > 0) { + this.nodesObservedForIntersection.set(ownerElement, filtered); + } else { + this.nodesObservedForIntersection.delete(ownerElement); + this.intersectionObserver.unobserve(ownerElement); + } } /** - * Translate child text nodes and attributes of target node + * Calls callback for child text nodes and attributes of target element */ - private handlerIntersectNode(node: Element) { + private triggerChildTextNodes(node: Element) { // WARNING: we shall not touch inner nodes, because its may still not intersected - // The element may contain nodes that are should not to translate. Filtered before calls onIntersected. - node.childNodes.forEach((node) => { - if (node instanceof Element || !this.filter(node)) return; - this.onIntersected(node); + const intersectedNode = this.nodesObservedForIntersection.get(node); + intersectedNode?.forEach(({ node, callback }) => { + callback(node); }); } } diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 5313658..36ae332 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -1,6 +1,5 @@ import { DOMNodesTranslator } from './DOMNodesTranslator'; import { IntersectionObserverWithFilter } from './IntersectionObserverWithFilter'; -import { isIntersectableNode } from './utils/isIntersectableNode'; import { visitWholeTree } from './utils/visitWholeTree'; export type TranslatableNodePredicate = (node: Node) => boolean; @@ -55,19 +54,12 @@ export class TranslationDispatcher { // Handle text nodes and attributes if (this.lazyTranslator) { - // Lazy translate when own element intersect viewport - // But translate at once if node have not parent (virtual node) or parent node is outside of body (utility tags like meta or title) + // if node is outside of body (utility tags like meta or title) translate immediately const isAttachedToDOM = node.getRootNode() !== node; - const observableNode = - node instanceof Attr ? node.ownerElement : node.parentElement; - - // Ignore lazy translation for non-intersecting nodes and translate it immediately - if ( - isAttachedToDOM && - observableNode !== null && - isIntersectableNode(observableNode) - ) { - this.lazyTranslator.attach(observableNode); + if (isAttachedToDOM) { + this.lazyTranslator.attach(node, (node: Node) => { + this.nodeTranslator.translateNode(node); + }); return; } } diff --git a/src/__tests__/IntersectionObserverWithFilter.test.ts b/src/__tests__/IntersectionObserverWithFilter.test.ts index cadf5f7..c4718b2 100644 --- a/src/__tests__/IntersectionObserverWithFilter.test.ts +++ b/src/__tests__/IntersectionObserverWithFilter.test.ts @@ -41,17 +41,14 @@ beforeEach(() => { vi.clearAllMocks(); }); -test('Call onIntersected for node from viewport', async () => { +test('Calls callback for node from viewport', async () => { const div = document.createElement('div'); div.textContent = 'Hello, World!'; document.body.appendChild(div); - const lazyTranslator = new IntersectionObserverWithFilter({ - filter: Boolean, - onIntersected: translator, - }); + const lazyTranslator = new IntersectionObserverWithFilter(); - lazyTranslator.attach(div); + lazyTranslator.attach(div.childNodes[0], translator); await awaitTranslation(); // The mock function was called once @@ -59,26 +56,17 @@ test('Call onIntersected for node from viewport', async () => { expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Call onIntersected for a node only when it becomes intersectable', async () => { - const lazyTranslator = new IntersectionObserverWithFilter({ - filter: Boolean, - onIntersected: translator, - }); +test('Calls callback for a node only when it becomes intersectable', async () => { + const lazyTranslator = new IntersectionObserverWithFilter(); - // node not attach to DOM, it not intersectable, not translate it + // node with display = 'none' is not intersectable + // node with the visible='hidden' property is considered intersectable, so use the display=none property instead const div = document.createElement('div'); div.textContent = 'Hello, world'; - - lazyTranslator.attach(div); - await awaitTranslation(); - - expect(translator.mock.calls).toEqual([]); - expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - - // Attach to the DOM, but elements with display = 'none' is not be intersectable, and not translate - // node with the visible='hidden' property is considered intersectable, so use the display=none property instead - document.body.appendChild(div); div.style.display = 'none'; + document.body.appendChild(div); + + lazyTranslator.attach(div.childNodes[0], translator); await awaitTranslation(); expect(translator.mock.calls).toEqual([]); @@ -92,11 +80,8 @@ test('Call onIntersected for a node only when it becomes intersectable', async ( expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Not call onIntersected after node is detached', async () => { - const lazyTranslator = new IntersectionObserverWithFilter({ - filter: Boolean, - onIntersected: translator, - }); +test('Not calls callback after node is detached', async () => { + const lazyTranslator = new IntersectionObserverWithFilter(); // create node with display=none, it not intersectible const div = document.createElement('div'); @@ -104,7 +89,7 @@ test('Not call onIntersected after node is detached', async () => { div.style.display = 'none'; document.body.appendChild(div); - lazyTranslator.attach(div); + lazyTranslator.attach(div.childNodes[0], translator); await awaitTranslation(); // not translate because node not visible @@ -112,7 +97,7 @@ test('Not call onIntersected after node is detached', async () => { expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); // node is detached - lazyTranslator.detach(div); + lazyTranslator.detach(div.childNodes[0]); // becomes visible and intersectable, but is still not translated after detach div.style.display = 'block'; await awaitTranslation(); @@ -121,11 +106,8 @@ test('Not call onIntersected after node is detached', async () => { expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Call onIntersected only after node intersect viewport', async () => { - const lazyTranslator = new IntersectionObserverWithFilter({ - filter: Boolean, - onIntersected: translator, - }); +test('Calls callback only after node intersect viewport', async () => { + const lazyTranslator = new IntersectionObserverWithFilter(); const div = document.createElement('div'); div.textContent = 'Hello world!'; document.body.appendChild(div); @@ -145,7 +127,7 @@ test('Call onIntersected only after node intersect viewport', async () => { y: 500, }); - lazyTranslator.attach(div); + lazyTranslator.attach(div.childNodes[0], translator); await awaitTranslation(); // don't translate because the node doesn't intersect the container @@ -169,11 +151,8 @@ test('Call onIntersected only after node intersect viewport', async () => { expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Not call a onIntersected for node that not intersect viewport after scrolling', async () => { - const lazyTranslator = new IntersectionObserverWithFilter({ - filter: Boolean, - onIntersected: translator, - }); +test('Not calls a callback for node that not intersect viewport after scrolling', async () => { + const lazyTranslator = new IntersectionObserverWithFilter(); const div = document.createElement('div'); div.textContent = 'Hello world!'; document.body.appendChild(div); @@ -193,7 +172,7 @@ test('Not call a onIntersected for node that not intersect viewport after scroll y: 400, }); - lazyTranslator.attach(div); + lazyTranslator.attach(div.childNodes[0], translator); await awaitTranslation(); // don't translate because the element doesn't intersect the container diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 9c7ee76..556375b 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -43,10 +43,7 @@ function buildTranslationServices( // not create instance if param lazyTranslate falsy const intersectionObserverWithFilter = config.lazyTranslate - ? new IntersectionObserverWithFilter({ - filter: isTranslatableNode, - onIntersected: domNodesTranslator.translateNode, - }) + ? new IntersectionObserverWithFilter() : undefined; const translatorDispatcher = new TranslationDispatcher({ diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index e642339..e57b3b1 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -6,7 +6,7 @@ import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from require('intersection-observer'); -const lazyTranslatorSpy = vi.spyOn(IntersectionObserverWithFilter.prototype, 'attach'); +// const lazyTranslatorSpy = vi.spyOn(IntersectionObserverWithFilter.prototype, 'attach'); beforeEach(() => { vi.clearAllMocks(); @@ -17,13 +17,11 @@ const isTranslatableNode = () => true; test('Translate immediately if node is not eligible for lazy translation', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); + const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: domNodesTranslator, - lazyTranslator: new IntersectionObserverWithFilter({ - filter: isTranslatableNode, - onIntersected: domNodesTranslator.translateNode, - }), + lazyTranslator: new IntersectionObserverWithFilter(), }); // OPTION node is not intersectable, node can`t translate 'lazy' @@ -37,7 +35,7 @@ test('Translate immediately if node is not eligible for lazy translation', async await awaitTranslation(); // lazy translator not called - expect(lazyTranslatorSpy.mock.calls).toEqual([]); + // expect(lazyTranslatorSpy.mock.calls).toEqual([]); // translate immediately expect(option.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); @@ -47,10 +45,7 @@ test('Lazily translate intersectable node', async () => { const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: domNodesTranslator, - lazyTranslator: new IntersectionObserverWithFilter({ - filter: isTranslatableNode, - onIntersected: domNodesTranslator.translateNode, - }), + lazyTranslator: new IntersectionObserverWithFilter(), }); const div = document.createElement('div'); @@ -60,7 +55,7 @@ test('Lazily translate intersectable node', async () => { await awaitTranslation(); // lazy translator called and translated element - expect(lazyTranslatorSpy.mock.calls).toEqual([[div]]); + // expect(lazyTranslatorSpy.mock.calls).toEqual([[div]]); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); @@ -100,10 +95,7 @@ test('Do not translate ignored node inside element during lazyTranslation', asyn const translationDispatcher = new TranslationDispatcher({ filter, nodeTranslator, - lazyTranslator: new IntersectionObserverWithFilter({ - filter, - onIntersected: nodeTranslator.translateNode, - }), + lazyTranslator: new IntersectionObserverWithFilter(), }); const div = document.createElement('div'); @@ -117,7 +109,7 @@ test('Do not translate ignored node inside element during lazyTranslation', asyn await awaitTranslation(); // call IntersectionObserverWithFilter with element - expect(lazyTranslatorSpy.mock.calls).toEqual([[div]]); + // expect(lazyTranslatorSpy.mock.calls).toEqual([[div]]); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); // comment not translated expect(comment.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); From cb8cdea4ae617e5864d35a03cab3c8cf50076ef3 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 17 May 2025 16:32:57 +0200 Subject: [PATCH 161/313] chore: rename --- src/DefaultNodesTranslator.ts | 4 ++-- ...Filter.ts => NodesIntersectionObserver.ts} | 8 +++---- src/TranslationDispatcher.ts | 8 +++---- ...t.ts => NodesIntersectionObserver.test.ts} | 24 +++++++++---------- src/__tests__/NodesTranslator.test.ts | 4 ++-- src/__tests__/TranslationDispatcher.test.ts | 8 +++---- src/index.ts | 2 +- 7 files changed, 29 insertions(+), 29 deletions(-) rename src/{IntersectionObserverWithFilter.ts => NodesIntersectionObserver.ts} (92%) rename src/__tests__/{IntersectionObserverWithFilter.test.ts => NodesIntersectionObserver.test.ts} (87%) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 0f49289..3f31ca5 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -3,7 +3,7 @@ import { NodesTranslator } from './NodesTranslator'; import { configureTranslatableNodePredicate } from './utils/nodes'; import { DOMNodesTranslator, - IntersectionObserverWithFilter, + NodesIntersectionObserver, TranslatableNodePredicate, TranslationDispatcher, } from '.'; @@ -28,7 +28,7 @@ export class DefaultNodesTranslator extends NodesTranslator { // not create instance if param lazyTranslate falsy const intersectionObserverWithFilter = lazyTranslate - ? new IntersectionObserverWithFilter() + ? new NodesIntersectionObserver() : undefined; const translatorDispatcher = new TranslationDispatcher({ diff --git a/src/IntersectionObserverWithFilter.ts b/src/NodesIntersectionObserver.ts similarity index 92% rename from src/IntersectionObserverWithFilter.ts rename to src/NodesIntersectionObserver.ts index 0a326e4..200b252 100644 --- a/src/IntersectionObserverWithFilter.ts +++ b/src/NodesIntersectionObserver.ts @@ -8,9 +8,9 @@ export function getElementOwnedNode(node: Node) { } /** - * Observes DOM elements and calls a callback for filtered child nodes when they intersect the viewport. + * Observes DOM nodes for intersection with the viewport and triggers callbacks when they become visible. */ -export class IntersectionObserverWithFilter { +export class NodesIntersectionObserver { private readonly intersectionObserver: IntersectionObserver; // Store the nodes and his parent element that is under observing for intersection @@ -43,7 +43,7 @@ export class IntersectionObserverWithFilter { * When the element that owns the node intersects the viewport, the callback is invoked. * Then the owner element and all its tracked nodes are automatically removed from observation. */ - public attach(node: Node, callback: (node: Node) => void) { + public observe(node: Node, callback: (node: Node) => void) { const ownerElement = getElementOwnedNode(node); // if node have not parent (virtual node) or not intersecteble calls callback immediately @@ -68,7 +68,7 @@ export class IntersectionObserverWithFilter { /** * Stops observing the node. It is removes from observation. */ - public detach(node: Node) { + public unobserve(node: Node) { const ownerElement = getElementOwnedNode(node); if (!ownerElement) return; diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 36ae332..7638ae6 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -1,5 +1,5 @@ import { DOMNodesTranslator } from './DOMNodesTranslator'; -import { IntersectionObserverWithFilter } from './IntersectionObserverWithFilter'; +import { NodesIntersectionObserver } from './NodesIntersectionObserver'; import { visitWholeTree } from './utils/visitWholeTree'; export type TranslatableNodePredicate = (node: Node) => boolean; @@ -21,7 +21,7 @@ export class TranslationDispatcher { }: { filter: TranslatableNodePredicate; nodeTranslator: DOMNodesTranslator; - lazyTranslator?: IntersectionObserverWithFilter; + lazyTranslator?: NodesIntersectionObserver; }) { this.filter = filter; this.nodeTranslator = nodeTranslator; @@ -57,7 +57,7 @@ export class TranslationDispatcher { // if node is outside of body (utility tags like meta or title) translate immediately const isAttachedToDOM = node.getRootNode() !== node; if (isAttachedToDOM) { - this.lazyTranslator.attach(node, (node: Node) => { + this.lazyTranslator.observe(node, (node: Node) => { this.nodeTranslator.translateNode(node); }); return; @@ -79,7 +79,7 @@ export class TranslationDispatcher { }); if (this.lazyTranslator) { - this.lazyTranslator.detach(node); + this.lazyTranslator.unobserve(node); } } diff --git a/src/__tests__/IntersectionObserverWithFilter.test.ts b/src/__tests__/NodesIntersectionObserver.test.ts similarity index 87% rename from src/__tests__/IntersectionObserverWithFilter.test.ts rename to src/__tests__/NodesIntersectionObserver.test.ts index c4718b2..badc80d 100644 --- a/src/__tests__/IntersectionObserverWithFilter.test.ts +++ b/src/__tests__/NodesIntersectionObserver.test.ts @@ -1,4 +1,4 @@ -import { IntersectionObserverWithFilter } from '../IntersectionObserverWithFilter'; +import { NodesIntersectionObserver } from '../NodesIntersectionObserver'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL } from './utils'; require('intersection-observer'); @@ -46,9 +46,9 @@ test('Calls callback for node from viewport', async () => { div.textContent = 'Hello, World!'; document.body.appendChild(div); - const lazyTranslator = new IntersectionObserverWithFilter(); + const lazyTranslator = new NodesIntersectionObserver(); - lazyTranslator.attach(div.childNodes[0], translator); + lazyTranslator.observe(div.childNodes[0], translator); await awaitTranslation(); // The mock function was called once @@ -57,7 +57,7 @@ test('Calls callback for node from viewport', async () => { }); test('Calls callback for a node only when it becomes intersectable', async () => { - const lazyTranslator = new IntersectionObserverWithFilter(); + const lazyTranslator = new NodesIntersectionObserver(); // node with display = 'none' is not intersectable // node with the visible='hidden' property is considered intersectable, so use the display=none property instead @@ -66,7 +66,7 @@ test('Calls callback for a node only when it becomes intersectable', async () => div.style.display = 'none'; document.body.appendChild(div); - lazyTranslator.attach(div.childNodes[0], translator); + lazyTranslator.observe(div.childNodes[0], translator); await awaitTranslation(); expect(translator.mock.calls).toEqual([]); @@ -81,7 +81,7 @@ test('Calls callback for a node only when it becomes intersectable', async () => }); test('Not calls callback after node is detached', async () => { - const lazyTranslator = new IntersectionObserverWithFilter(); + const lazyTranslator = new NodesIntersectionObserver(); // create node with display=none, it not intersectible const div = document.createElement('div'); @@ -89,7 +89,7 @@ test('Not calls callback after node is detached', async () => { div.style.display = 'none'; document.body.appendChild(div); - lazyTranslator.attach(div.childNodes[0], translator); + lazyTranslator.observe(div.childNodes[0], translator); await awaitTranslation(); // not translate because node not visible @@ -97,7 +97,7 @@ test('Not calls callback after node is detached', async () => { expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); // node is detached - lazyTranslator.detach(div.childNodes[0]); + lazyTranslator.unobserve(div.childNodes[0]); // becomes visible and intersectable, but is still not translated after detach div.style.display = 'block'; await awaitTranslation(); @@ -107,7 +107,7 @@ test('Not calls callback after node is detached', async () => { }); test('Calls callback only after node intersect viewport', async () => { - const lazyTranslator = new IntersectionObserverWithFilter(); + const lazyTranslator = new NodesIntersectionObserver(); const div = document.createElement('div'); div.textContent = 'Hello world!'; document.body.appendChild(div); @@ -127,7 +127,7 @@ test('Calls callback only after node intersect viewport', async () => { y: 500, }); - lazyTranslator.attach(div.childNodes[0], translator); + lazyTranslator.observe(div.childNodes[0], translator); await awaitTranslation(); // don't translate because the node doesn't intersect the container @@ -152,7 +152,7 @@ test('Calls callback only after node intersect viewport', async () => { }); test('Not calls a callback for node that not intersect viewport after scrolling', async () => { - const lazyTranslator = new IntersectionObserverWithFilter(); + const lazyTranslator = new NodesIntersectionObserver(); const div = document.createElement('div'); div.textContent = 'Hello world!'; document.body.appendChild(div); @@ -172,7 +172,7 @@ test('Not calls a callback for node that not intersect viewport after scrolling' y: 400, }); - lazyTranslator.attach(div.childNodes[0], translator); + lazyTranslator.observe(div.childNodes[0], translator); await awaitTranslation(); // don't translate because the element doesn't intersect the container diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 556375b..2cb13bf 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'fs'; import { Config } from '../DefaultNodesTranslator'; import { DOMNodesTranslator, TranslatorInterface } from '../DOMNodesTranslator'; -import { IntersectionObserverWithFilter } from '../IntersectionObserverWithFilter'; +import { NodesIntersectionObserver } from '../NodesIntersectionObserver'; import { NodesTranslator } from '../NodesTranslator'; import { TranslationDispatcher } from '../TranslationDispatcher'; import { configureTranslatableNodePredicate, NodesFilterOptions } from '../utils/nodes'; @@ -43,7 +43,7 @@ function buildTranslationServices( // not create instance if param lazyTranslate falsy const intersectionObserverWithFilter = config.lazyTranslate - ? new IntersectionObserverWithFilter() + ? new NodesIntersectionObserver() : undefined; const translatorDispatcher = new TranslationDispatcher({ diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index e57b3b1..f4d0247 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -1,5 +1,5 @@ import { DOMNodesTranslator } from '../DOMNodesTranslator'; -import { IntersectionObserverWithFilter } from '../IntersectionObserverWithFilter'; +import { NodesIntersectionObserver } from '../NodesIntersectionObserver'; import { TranslationDispatcher } from '../TranslationDispatcher'; import { configureTranslatableNodePredicate } from '../utils/nodes'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; @@ -21,7 +21,7 @@ test('Translate immediately if node is not eligible for lazy translation', async const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: domNodesTranslator, - lazyTranslator: new IntersectionObserverWithFilter(), + lazyTranslator: new NodesIntersectionObserver(), }); // OPTION node is not intersectable, node can`t translate 'lazy' @@ -45,7 +45,7 @@ test('Lazily translate intersectable node', async () => { const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: domNodesTranslator, - lazyTranslator: new IntersectionObserverWithFilter(), + lazyTranslator: new NodesIntersectionObserver(), }); const div = document.createElement('div'); @@ -95,7 +95,7 @@ test('Do not translate ignored node inside element during lazyTranslation', asyn const translationDispatcher = new TranslationDispatcher({ filter, nodeTranslator, - lazyTranslator: new IntersectionObserverWithFilter(), + lazyTranslator: new NodesIntersectionObserver(), }); const div = document.createElement('div'); diff --git a/src/index.ts b/src/index.ts index 51851f7..f1c6999 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export * from './NodesTranslator'; export * from './TranslationDispatcher'; export * from './DOMNodesTranslator'; -export * from './IntersectionObserverWithFilter'; +export * from './NodesIntersectionObserver'; export * from './DefaultNodesTranslator'; From f0299ea1acf513772f08dd1e17caa81f7705a128 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 17 May 2025 17:37:24 +0200 Subject: [PATCH 162/313] test: add test, improve existing test --- src/__tests__/TranslationDispatcher.test.ts | 43 ++++++++++++--------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index f4d0247..8d8c593 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -6,8 +6,6 @@ import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from require('intersection-observer'); -// const lazyTranslatorSpy = vi.spyOn(IntersectionObserverWithFilter.prototype, 'attach'); - beforeEach(() => { vi.clearAllMocks(); document.body.innerHTML = ''; @@ -15,7 +13,7 @@ beforeEach(() => { const isTranslatableNode = () => true; -test('Translate immediately if node is not eligible for lazy translation', async () => { +test('Translate node that is not suitable for delayed translation', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const translationDispatcher = new TranslationDispatcher({ @@ -24,7 +22,7 @@ test('Translate immediately if node is not eligible for lazy translation', async lazyTranslator: new NodesIntersectionObserver(), }); - // OPTION node is not intersectable, node can`t translate 'lazy' + // OPTION node is not intersectable, node can`t translate latter const select = document.createElement('select'); const option = document.createElement('option'); option.textContent = 'Hello, world!'; @@ -34,29 +32,35 @@ test('Translate immediately if node is not eligible for lazy translation', async translationDispatcher.translateNode(select); await awaitTranslation(); - // lazy translator not called - // expect(lazyTranslatorSpy.mock.calls).toEqual([]); - // translate immediately expect(option.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Lazily translate intersectable node', async () => { +test('Translate node from shadowDom ', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); + const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: domNodesTranslator, lazyTranslator: new NodesIntersectionObserver(), }); + const host = document.createElement('div'); + document.body.appendChild(host); + const shadowRoot = host.attachShadow({ mode: 'open' }); + + // this node not attached to DOM, but should be translated const div = document.createElement('div'); - div.textContent = 'Hello, world!'; - document.body.appendChild(div); - translationDispatcher.translateNode(div); - await awaitTranslation(); + const text = 'I`m from shadow'; + div.textContent = text; + shadowRoot.appendChild(div); - // lazy translator called and translated element - // expect(lazyTranslatorSpy.mock.calls).toEqual([[div]]); + translationDispatcher.translateNode(host); + await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // restore + translationDispatcher.restoreNode(host); + expect(div.textContent).toBe(text); }); test('Translates and restores the element and its child elements', async () => { @@ -87,7 +91,7 @@ test('Translates and restores the element and its child elements', async () => { expect(div1.childNodes[0].textContent).toBe(text1); }); -test('Do not translate ignored node inside element during lazyTranslation', async () => { +test('Do not translate ignored node inside element', async () => { const filter = configureTranslatableNodePredicate({ ignoredSelectors: ['comment'], }); @@ -101,16 +105,17 @@ test('Do not translate ignored node inside element during lazyTranslation', asyn const div = document.createElement('div'); div.textContent = 'I`m block i have four corners'; const comment = document.createComment('I`m comment node, not translate me please'); + const p = document.createElement('p'); + p.textContent = 'I have text, i would be translated'; + div.appendChild(p); div.appendChild(comment); document.body.appendChild(div); - // IntersectionObserverWithFilter receives a div element containing a comment that should not be translated translationDispatcher.translateNode(div); await awaitTranslation(); - // call IntersectionObserverWithFilter with element - // expect(lazyTranslatorSpy.mock.calls).toEqual([[div]]); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(p.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); // comment not translated expect(comment.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); From 271d7c1de4fed3a0fd9b455850dd82345f0b2c2d Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 17 May 2025 17:38:53 +0200 Subject: [PATCH 163/313] chore: rename --- src/DefaultNodesTranslator.ts | 2 +- src/TranslationDispatcher.ts | 20 ++++++++++---------- src/__tests__/NodesTranslator.test.ts | 2 +- src/__tests__/TranslationDispatcher.test.ts | 6 +++--- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 3f31ca5..c54046a 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -34,7 +34,7 @@ export class DefaultNodesTranslator extends NodesTranslator { const translatorDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: domNodesTranslator, - lazyTranslator: intersectionObserverWithFilter, + nodeIntersectionObserver: intersectionObserverWithFilter, }); super({ diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 7638ae6..91113e4 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -12,20 +12,20 @@ export class TranslationDispatcher { private readonly filter; private readonly nodeTranslator; // if dependency is not passed, then the node will not be translated lazy - private readonly lazyTranslator; + private readonly nodeIntersectionObserver; constructor({ filter, nodeTranslator, - lazyTranslator, + nodeIntersectionObserver, }: { filter: TranslatableNodePredicate; nodeTranslator: DOMNodesTranslator; - lazyTranslator?: NodesIntersectionObserver; + nodeIntersectionObserver?: NodesIntersectionObserver; }) { this.filter = filter; this.nodeTranslator = nodeTranslator; - this.lazyTranslator = lazyTranslator || null; + this.nodeIntersectionObserver = nodeIntersectionObserver || null; } public updateNode(node: Node) { @@ -53,11 +53,12 @@ export class TranslationDispatcher { // Handle text nodes and attributes - if (this.lazyTranslator) { + // translate latter if possible + if (this.nodeIntersectionObserver) { // if node is outside of body (utility tags like meta or title) translate immediately const isAttachedToDOM = node.getRootNode() !== node; if (isAttachedToDOM) { - this.lazyTranslator.observe(node, (node: Node) => { + this.nodeIntersectionObserver.observe(node, (node: Node) => { this.nodeTranslator.translateNode(node); }); return; @@ -77,10 +78,9 @@ export class TranslationDispatcher { visitWholeTree(node, (node) => { this.restoreNode(node, true); }); - - if (this.lazyTranslator) { - this.lazyTranslator.unobserve(node); - } + } + if (this.nodeIntersectionObserver) { + this.nodeIntersectionObserver.unobserve(node); } this.nodeTranslator.restoreNode(node); diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 2cb13bf..a6bb7bc 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -49,7 +49,7 @@ function buildTranslationServices( const translatorDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: domNodesTranslator, - lazyTranslator: intersectionObserverWithFilter, + nodeIntersectionObserver: intersectionObserverWithFilter, }); return { diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 8d8c593..3052aa4 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -19,7 +19,7 @@ test('Translate node that is not suitable for delayed translation', async () => const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: domNodesTranslator, - lazyTranslator: new NodesIntersectionObserver(), + nodeIntersectionObserver: new NodesIntersectionObserver(), }); // OPTION node is not intersectable, node can`t translate latter @@ -41,7 +41,7 @@ test('Translate node from shadowDom ', async () => { const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: domNodesTranslator, - lazyTranslator: new NodesIntersectionObserver(), + nodeIntersectionObserver: new NodesIntersectionObserver(), }); const host = document.createElement('div'); @@ -99,7 +99,7 @@ test('Do not translate ignored node inside element', async () => { const translationDispatcher = new TranslationDispatcher({ filter, nodeTranslator, - lazyTranslator: new NodesIntersectionObserver(), + nodeIntersectionObserver: new NodesIntersectionObserver(), }); const div = document.createElement('div'); From 6b3af9469ea852b3596738e0df64d5a328ecc270 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 17 May 2025 17:45:12 +0200 Subject: [PATCH 164/313] chore: improve docs --- src/NodesIntersectionObserver.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/NodesIntersectionObserver.ts b/src/NodesIntersectionObserver.ts index 200b252..b19d889 100644 --- a/src/NodesIntersectionObserver.ts +++ b/src/NodesIntersectionObserver.ts @@ -13,7 +13,7 @@ export function getElementOwnedNode(node: Node) { export class NodesIntersectionObserver { private readonly intersectionObserver: IntersectionObserver; - // Store the nodes and his parent element that is under observing for intersection + // Store the nodes and his owner element that is under observing for intersection private readonly nodesObservedForIntersection = new WeakMap< Element, { node: Node; callback: (node: Node) => void }[] @@ -75,8 +75,8 @@ export class NodesIntersectionObserver { const observedNodes = this.nodesObservedForIntersection.get(ownerElement); const filtered = observedNodes?.filter((entry) => entry.node !== node); - - if (filtered && filtered?.length > 0) { + if (filtered) { + // delete only the received node from storage this.nodesObservedForIntersection.set(ownerElement, filtered); } else { this.nodesObservedForIntersection.delete(ownerElement); @@ -85,10 +85,9 @@ export class NodesIntersectionObserver { } /** - * Calls callback for child text nodes and attributes of target element + * Calls callbacks for all observed nodes associated with the specified element */ private triggerChildTextNodes(node: Element) { - // WARNING: we shall not touch inner nodes, because its may still not intersected const intersectedNode = this.nodesObservedForIntersection.get(node); intersectedNode?.forEach(({ node, callback }) => { callback(node); From 8be74282c7501ffc4d55fb2ad705e3c7d1f80565 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 17 May 2025 19:01:35 +0200 Subject: [PATCH 165/313] chore: rename --- src/DefaultNodesTranslator.ts | 4 ++-- src/__tests__/NodesTranslator.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index c54046a..fc48de2 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -27,14 +27,14 @@ export class DefaultNodesTranslator extends NodesTranslator { const domNodesTranslator = new DOMNodesTranslator(translateCallback); // not create instance if param lazyTranslate falsy - const intersectionObserverWithFilter = lazyTranslate + const nodeIntersectionObserver = lazyTranslate ? new NodesIntersectionObserver() : undefined; const translatorDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: domNodesTranslator, - nodeIntersectionObserver: intersectionObserverWithFilter, + nodeIntersectionObserver, }); super({ diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index a6bb7bc..3fb8d48 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -42,14 +42,14 @@ function buildTranslationServices( const domNodesTranslator = new DOMNodesTranslator(translateCallback); // not create instance if param lazyTranslate falsy - const intersectionObserverWithFilter = config.lazyTranslate + const nodeIntersectionObserver = config.lazyTranslate ? new NodesIntersectionObserver() : undefined; const translatorDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: domNodesTranslator, - nodeIntersectionObserver: intersectionObserverWithFilter, + nodeIntersectionObserver, }); return { From 4113f49ade425734a8f9b60cf4847fe8dd6c2e88 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 17 May 2025 21:46:21 +0200 Subject: [PATCH 166/313] refactor: fix error and improve docs --- src/NodesIntersectionObserver.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/NodesIntersectionObserver.ts b/src/NodesIntersectionObserver.ts index b19d889..a675038 100644 --- a/src/NodesIntersectionObserver.ts +++ b/src/NodesIntersectionObserver.ts @@ -46,23 +46,23 @@ export class NodesIntersectionObserver { public observe(node: Node, callback: (node: Node) => void) { const ownerElement = getElementOwnedNode(node); - // if node have not parent (virtual node) or not intersecteble calls callback immediately + // immediately invoke callback if node has no owner or is not intersectable if (!ownerElement || !isIntersectableNode(ownerElement)) { callback(node); return; } // add observableNode if not exist - const observedNodes = this.nodesObservedForIntersection.get(ownerElement); const entry = { node, callback }; + const observedNodes = this.nodesObservedForIntersection.get(ownerElement); + if (observedNodes) { observedNodes?.push(entry); } else { this.nodesObservedForIntersection.set(ownerElement, [entry]); + // start observe element for intersection + this.intersectionObserver.observe(ownerElement); } - - // start observe element for intersection - this.intersectionObserver.observe(ownerElement); } /** @@ -73,9 +73,10 @@ export class NodesIntersectionObserver { if (!ownerElement) return; const observedNodes = this.nodesObservedForIntersection.get(ownerElement); + if (!observedNodes) return; const filtered = observedNodes?.filter((entry) => entry.node !== node); - if (filtered) { + if (filtered.length > 0) { // delete only the received node from storage this.nodesObservedForIntersection.set(ownerElement, filtered); } else { From 41e98da140157a8b47e03b934a5cfeeee1db6874 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 17 May 2025 21:48:24 +0200 Subject: [PATCH 167/313] test: change test case --- src/__tests__/TranslationDispatcher.test.ts | 25 +++++++++------------ 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 3052aa4..5db7945 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -15,7 +15,6 @@ const isTranslatableNode = () => true; test('Translate node that is not suitable for delayed translation', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); - const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: domNodesTranslator, @@ -35,32 +34,28 @@ test('Translate node that is not suitable for delayed translation', async () => expect(option.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Translate node from shadowDom ', async () => { +test('Translate node not attached to DOM', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); - const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: domNodesTranslator, nodeIntersectionObserver: new NodesIntersectionObserver(), }); - const host = document.createElement('div'); - document.body.appendChild(host); - const shadowRoot = host.attachShadow({ mode: 'open' }); - // this node not attached to DOM, but should be translated - const div = document.createElement('div'); - const text = 'I`m from shadow'; - div.textContent = text; - shadowRoot.appendChild(div); + const head = document.createElement('head'); + const title = document.createElement('title'); + const text = 'Title can contain only text'; + title.textContent = text; + head.appendChild(title); - translationDispatcher.translateNode(host); + translationDispatcher.translateNode(head); await awaitTranslation(); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(title.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); // restore - translationDispatcher.restoreNode(host); - expect(div.textContent).toBe(text); + translationDispatcher.restoreNode(head); + expect(title.textContent).toBe(text); }); test('Translates and restores the element and its child elements', async () => { From a73575f136c48178d2e0d91a1092e61c01c1b8d2 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 17 May 2025 21:56:18 +0200 Subject: [PATCH 168/313] test: change text node first --- src/__tests__/DOMNodesTranslator.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index ee6c0eb..d86df38 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -71,10 +71,10 @@ test('Translate the node after updating its text', async () => { // the first call updateNode will update the updateId state, but the node won’t be translated // because the internal state updateId will be equal to translateContext, this approach prevent recursion translation - domNodesTranslator.updateNode(node.attributes[0]); - await awaitTranslation(); const text1 = 'title text is update'; node.setAttribute('title', text1); + domNodesTranslator.updateNode(node.attributes[0]); + await awaitTranslation(); // this call will translate node text domNodesTranslator.updateNode(node.attributes[0]); From edf132998c1695ef751d970a88a652b1514512bb Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 17 May 2025 22:28:46 +0200 Subject: [PATCH 169/313] fix: avoid duplication in store --- src/NodesIntersectionObserver.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/NodesIntersectionObserver.ts b/src/NodesIntersectionObserver.ts index a675038..48301b9 100644 --- a/src/NodesIntersectionObserver.ts +++ b/src/NodesIntersectionObserver.ts @@ -13,7 +13,7 @@ export function getElementOwnedNode(node: Node) { export class NodesIntersectionObserver { private readonly intersectionObserver: IntersectionObserver; - // Store the nodes and his owner element that is under observing for intersection + // Stores the nodes and callback for this node, and his owner element that is under observing for intersection private readonly nodesObservedForIntersection = new WeakMap< Element, { node: Node; callback: (node: Node) => void }[] @@ -52,15 +52,16 @@ export class NodesIntersectionObserver { return; } - // add observableNode if not exist const entry = { node, callback }; const observedNodes = this.nodesObservedForIntersection.get(ownerElement); if (observedNodes) { + // add node to array only if not exist yet + const isNodeAlreadyObserve = observedNodes.some((n) => n.node === node); + if (isNodeAlreadyObserve) return; observedNodes?.push(entry); } else { this.nodesObservedForIntersection.set(ownerElement, [entry]); - // start observe element for intersection this.intersectionObserver.observe(ownerElement); } } @@ -75,11 +76,12 @@ export class NodesIntersectionObserver { const observedNodes = this.nodesObservedForIntersection.get(ownerElement); if (!observedNodes) return; - const filtered = observedNodes?.filter((entry) => entry.node !== node); + const filtered = observedNodes.filter((entry) => entry.node !== node); if (filtered.length > 0) { - // delete only the received node from storage + // delete only the received node this.nodesObservedForIntersection.set(ownerElement, filtered); } else { + // if no more nodes are tracked under this ownerElement, stop observing the ownerElement this.nodesObservedForIntersection.delete(ownerElement); this.intersectionObserver.unobserve(ownerElement); } From 532d6e1bcda5773a739d0936a1c09bc2e9948c49 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 17 May 2025 22:45:52 +0200 Subject: [PATCH 170/313] chore: rename --- src/NodesIntersectionObserver.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/NodesIntersectionObserver.ts b/src/NodesIntersectionObserver.ts index 48301b9..13de993 100644 --- a/src/NodesIntersectionObserver.ts +++ b/src/NodesIntersectionObserver.ts @@ -54,11 +54,10 @@ export class NodesIntersectionObserver { const entry = { node, callback }; const observedNodes = this.nodesObservedForIntersection.get(ownerElement); - if (observedNodes) { // add node to array only if not exist yet - const isNodeAlreadyObserve = observedNodes.some((n) => n.node === node); - if (isNodeAlreadyObserve) return; + const isNodeExist = observedNodes.some((n) => n.node === node); + if (isNodeExist) return; observedNodes?.push(entry); } else { this.nodesObservedForIntersection.set(ownerElement, [entry]); @@ -72,7 +71,6 @@ export class NodesIntersectionObserver { public unobserve(node: Node) { const ownerElement = getElementOwnedNode(node); if (!ownerElement) return; - const observedNodes = this.nodesObservedForIntersection.get(ownerElement); if (!observedNodes) return; From e2b1afefc4afe75139bfee4f2c6ff986ec6e54f6 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 17 May 2025 23:02:53 +0200 Subject: [PATCH 171/313] chore: improve style --- src/NodesIntersectionObserver.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/NodesIntersectionObserver.ts b/src/NodesIntersectionObserver.ts index 13de993..0387efa 100644 --- a/src/NodesIntersectionObserver.ts +++ b/src/NodesIntersectionObserver.ts @@ -54,15 +54,15 @@ export class NodesIntersectionObserver { const entry = { node, callback }; const observedNodes = this.nodesObservedForIntersection.get(ownerElement); + // add node to array only if not exist yet if (observedNodes) { - // add node to array only if not exist yet - const isNodeExist = observedNodes.some((n) => n.node === node); - if (isNodeExist) return; + const isNodeAlreadyObserve = observedNodes.some((n) => n.node === node); + if (isNodeAlreadyObserve) return; observedNodes?.push(entry); - } else { - this.nodesObservedForIntersection.set(ownerElement, [entry]); - this.intersectionObserver.observe(ownerElement); + return; } + this.nodesObservedForIntersection.set(ownerElement, [entry]); + this.intersectionObserver.observe(ownerElement); } /** @@ -75,14 +75,14 @@ export class NodesIntersectionObserver { if (!observedNodes) return; const filtered = observedNodes.filter((entry) => entry.node !== node); + // delete only the received node if (filtered.length > 0) { - // delete only the received node this.nodesObservedForIntersection.set(ownerElement, filtered); - } else { - // if no more nodes are tracked under this ownerElement, stop observing the ownerElement - this.nodesObservedForIntersection.delete(ownerElement); - this.intersectionObserver.unobserve(ownerElement); + return; } + // if no more nodes are tracked under this ownerElement, stop observing the ownerElement + this.nodesObservedForIntersection.delete(ownerElement); + this.intersectionObserver.unobserve(ownerElement); } /** From 6e7c2d4fc06ec3d95154de537a9adba644fad78a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 17 May 2025 23:04:40 +0200 Subject: [PATCH 172/313] chore: improve style --- src/NodesIntersectionObserver.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/NodesIntersectionObserver.ts b/src/NodesIntersectionObserver.ts index 0387efa..d9340f9 100644 --- a/src/NodesIntersectionObserver.ts +++ b/src/NodesIntersectionObserver.ts @@ -54,13 +54,16 @@ export class NodesIntersectionObserver { const entry = { node, callback }; const observedNodes = this.nodesObservedForIntersection.get(ownerElement); + // add node to array only if not exist yet if (observedNodes) { - const isNodeAlreadyObserve = observedNodes.some((n) => n.node === node); - if (isNodeAlreadyObserve) return; + const isNodeExist = observedNodes.some((n) => n.node === node); + if (isNodeExist) return; + observedNodes?.push(entry); return; } + this.nodesObservedForIntersection.set(ownerElement, [entry]); this.intersectionObserver.observe(ownerElement); } @@ -80,6 +83,7 @@ export class NodesIntersectionObserver { this.nodesObservedForIntersection.set(ownerElement, filtered); return; } + // if no more nodes are tracked under this ownerElement, stop observing the ownerElement this.nodesObservedForIntersection.delete(ownerElement); this.intersectionObserver.unobserve(ownerElement); From b746ea066221e51af0f48e97d0f39a3cce80501c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 18 May 2025 01:54:26 +0200 Subject: [PATCH 173/313] refactor: improve data sctucture for avoid iteration --- src/NodesIntersectionObserver.ts | 70 +++++++++++++++++--------------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/src/NodesIntersectionObserver.ts b/src/NodesIntersectionObserver.ts index d9340f9..a267bbb 100644 --- a/src/NodesIntersectionObserver.ts +++ b/src/NodesIntersectionObserver.ts @@ -7,17 +7,17 @@ export function getElementOwnedNode(node: Node) { return node instanceof Attr ? node.ownerElement : node.parentElement; } +type Callback = (node: Node) => void; + /** * Observes DOM nodes for intersection with the viewport and triggers callbacks when they become visible. */ export class NodesIntersectionObserver { private readonly intersectionObserver: IntersectionObserver; - // Stores the nodes and callback for this node, and his owner element that is under observing for intersection - private readonly nodesObservedForIntersection = new WeakMap< - Element, - { node: Node; callback: (node: Node) => void }[] - >(); + private readonly nodeCallbacksMap = new WeakMap(); + // Stores the nodes and his owner element that is under observing for intersection + private readonly elementNodesMap = new WeakMap>(); constructor(intersectionConfig?: IntersectionObserverInit) { this.intersectionObserver = new IntersectionObserver((entries, observer) => { @@ -25,14 +25,14 @@ export class NodesIntersectionObserver { const node = entry.target; // Skip nodes that are not under observation or still is not intersected - if (!this.nodesObservedForIntersection.has(node) || !entry.isIntersecting) - return; + if (!this.elementNodesMap.has(node) || !entry.isIntersecting) return; - this.triggerChildTextNodes(node); + this.triggerNestedNodes(node); // Process the element once and forget it // This makes it possible to observe the element again later if needed - this.nodesObservedForIntersection.delete(node); + this.elementNodesMap.delete(node); + this.nodeCallbacksMap.delete(node); observer.unobserve(node); }); }, intersectionConfig); @@ -43,7 +43,7 @@ export class NodesIntersectionObserver { * When the element that owns the node intersects the viewport, the callback is invoked. * Then the owner element and all its tracked nodes are automatically removed from observation. */ - public observe(node: Node, callback: (node: Node) => void) { + public observe(node: Node, callback: Callback) { const ownerElement = getElementOwnedNode(node); // immediately invoke callback if node has no owner or is not intersectable @@ -52,19 +52,21 @@ export class NodesIntersectionObserver { return; } - const entry = { node, callback }; - const observedNodes = this.nodesObservedForIntersection.get(ownerElement); - - // add node to array only if not exist yet + // add node to set only if not exist yet + const observedNodes = this.elementNodesMap.get(ownerElement); if (observedNodes) { - const isNodeExist = observedNodes.some((n) => n.node === node); - if (isNodeExist) return; + // set callback for node + this.nodeCallbacksMap.set(node, callback); - observedNodes?.push(entry); + const isNodeExist = observedNodes.has(node); + if (!isNodeExist) { + observedNodes.add(node); + } return; } - this.nodesObservedForIntersection.set(ownerElement, [entry]); + this.elementNodesMap.set(ownerElement, new Set().add(node)); + this.nodeCallbacksMap.set(node, callback); this.intersectionObserver.observe(ownerElement); } @@ -74,28 +76,32 @@ export class NodesIntersectionObserver { public unobserve(node: Node) { const ownerElement = getElementOwnedNode(node); if (!ownerElement) return; - const observedNodes = this.nodesObservedForIntersection.get(ownerElement); + const observedNodes = this.elementNodesMap.get(ownerElement); if (!observedNodes) return; - const filtered = observedNodes.filter((entry) => entry.node !== node); - // delete only the received node - if (filtered.length > 0) { - this.nodesObservedForIntersection.set(ownerElement, filtered); - return; + if (observedNodes.size === 0) { + // if no more nodes are tracked under this ownerElement, stop observing the ownerElement + this.elementNodesMap.delete(ownerElement); + this.nodeCallbacksMap.delete(node); + this.intersectionObserver.unobserve(ownerElement); + } else { + if (observedNodes.has(node)) { + // delete only the received node + observedNodes.delete(node); + this.nodeCallbacksMap.delete(node); + return; + } } - - // if no more nodes are tracked under this ownerElement, stop observing the ownerElement - this.nodesObservedForIntersection.delete(ownerElement); - this.intersectionObserver.unobserve(ownerElement); } /** * Calls callbacks for all observed nodes associated with the specified element */ - private triggerChildTextNodes(node: Element) { - const intersectedNode = this.nodesObservedForIntersection.get(node); - intersectedNode?.forEach(({ node, callback }) => { - callback(node); + private triggerNestedNodes(node: Element) { + const intersectedNodes = this.elementNodesMap.get(node); + intersectedNodes?.forEach((node) => { + const callback = this.nodeCallbacksMap.get(node); + if (callback) callback(node); }); } } From 4fec4184cd1ddc380e68a0776ace133bdb3d351a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 18 May 2025 01:56:10 +0200 Subject: [PATCH 174/313] chore: rename --- src/NodesIntersectionObserver.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/NodesIntersectionObserver.ts b/src/NodesIntersectionObserver.ts index a267bbb..d984feb 100644 --- a/src/NodesIntersectionObserver.ts +++ b/src/NodesIntersectionObserver.ts @@ -3,7 +3,7 @@ import { isIntersectableNode } from './utils/isIntersectableNode'; /** * @returns Returns the node owner element. */ -export function getElementOwnedNode(node: Node) { +export function getElementOfNode(node: Node) { return node instanceof Attr ? node.ownerElement : node.parentElement; } @@ -44,7 +44,7 @@ export class NodesIntersectionObserver { * Then the owner element and all its tracked nodes are automatically removed from observation. */ public observe(node: Node, callback: Callback) { - const ownerElement = getElementOwnedNode(node); + const ownerElement = getElementOfNode(node); // immediately invoke callback if node has no owner or is not intersectable if (!ownerElement || !isIntersectableNode(ownerElement)) { @@ -74,7 +74,7 @@ export class NodesIntersectionObserver { * Stops observing the node. It is removes from observation. */ public unobserve(node: Node) { - const ownerElement = getElementOwnedNode(node); + const ownerElement = getElementOfNode(node); if (!ownerElement) return; const observedNodes = this.elementNodesMap.get(ownerElement); if (!observedNodes) return; From 95010bb890b57c46e3f9061486e722c7c9714ac6 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 18 May 2025 02:02:07 +0200 Subject: [PATCH 175/313] chore: improve style and update docs --- src/NodesIntersectionObserver.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/NodesIntersectionObserver.ts b/src/NodesIntersectionObserver.ts index d984feb..b32c949 100644 --- a/src/NodesIntersectionObserver.ts +++ b/src/NodesIntersectionObserver.ts @@ -11,10 +11,10 @@ type Callback = (node: Node) => void; /** * Observes DOM nodes for intersection with the viewport and triggers callbacks when they become visible. + * WARNING: Class works with nodes, not elements directly */ export class NodesIntersectionObserver { private readonly intersectionObserver: IntersectionObserver; - private readonly nodeCallbacksMap = new WeakMap(); // Stores the nodes and his owner element that is under observing for intersection private readonly elementNodesMap = new WeakMap>(); @@ -85,12 +85,11 @@ export class NodesIntersectionObserver { this.nodeCallbacksMap.delete(node); this.intersectionObserver.unobserve(ownerElement); } else { - if (observedNodes.has(node)) { - // delete only the received node - observedNodes.delete(node); - this.nodeCallbacksMap.delete(node); - return; - } + // delete only the received node + if (!observedNodes.has(node)) return; + + observedNodes.delete(node); + this.nodeCallbacksMap.delete(node); } } From 4bdc3c58d8414f5ae12dbd5d70865233ac70f0ee Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 18 May 2025 02:11:44 +0200 Subject: [PATCH 176/313] chore: add docs for config param --- src/TranslationDispatcher.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 91113e4..d9d8d8a 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -11,7 +11,6 @@ export type TranslatableNodePredicate = (node: Node) => boolean; export class TranslationDispatcher { private readonly filter; private readonly nodeTranslator; - // if dependency is not passed, then the node will not be translated lazy private readonly nodeIntersectionObserver; constructor({ @@ -21,6 +20,9 @@ export class TranslationDispatcher { }: { filter: TranslatableNodePredicate; nodeTranslator: DOMNodesTranslator; + /** + * If nodeIntersectionObserver is passed then node can be translate delayed - after intersect viewport + */ nodeIntersectionObserver?: NodesIntersectionObserver; }) { this.filter = filter; From 117392fdf0e22d2916193647803a499566e1186a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 18 May 2025 02:11:53 +0200 Subject: [PATCH 177/313] chore: rename --- src/NodesIntersectionObserver.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/NodesIntersectionObserver.ts b/src/NodesIntersectionObserver.ts index b32c949..d50f0d2 100644 --- a/src/NodesIntersectionObserver.ts +++ b/src/NodesIntersectionObserver.ts @@ -97,8 +97,8 @@ export class NodesIntersectionObserver { * Calls callbacks for all observed nodes associated with the specified element */ private triggerNestedNodes(node: Element) { - const intersectedNodes = this.elementNodesMap.get(node); - intersectedNodes?.forEach((node) => { + const ownedNodes = this.elementNodesMap.get(node); + ownedNodes?.forEach((node) => { const callback = this.nodeCallbacksMap.get(node); if (callback) callback(node); }); From ede8b1f1833cd13d81b61670c4043e9227f44e8c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 18 May 2025 02:13:56 +0200 Subject: [PATCH 178/313] chore: remove to utils --- .../NodesIntersectionObserver.test.ts | 30 ++++--------------- src/__tests__/utils.ts | 25 ++++++++++++++++ 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/src/__tests__/NodesIntersectionObserver.test.ts b/src/__tests__/NodesIntersectionObserver.test.ts index badc80d..d84182b 100644 --- a/src/__tests__/NodesIntersectionObserver.test.ts +++ b/src/__tests__/NodesIntersectionObserver.test.ts @@ -1,5 +1,10 @@ import { NodesIntersectionObserver } from '../NodesIntersectionObserver'; -import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL } from './utils'; +import { + awaitTranslation, + containsRegex, + mockBoundingClientRect, + TRANSLATION_SYMBOL, +} from './utils'; require('intersection-observer'); @@ -7,29 +12,6 @@ const translator = vi.fn().mockImplementation(async (node: Node) => { node.textContent += TRANSLATION_SYMBOL; }); -// jsdom does not actually modify element coordinates -// Create a mock that sets the real values for the coordinates -const mockBoundingClientRect = ( - element: HTMLElement, - rect: { - width: number; - height: number; - x: number; - y: number; - }, -) => { - Object.defineProperty(element, 'getBoundingClientRect', { - configurable: true, - value: () => ({ - top: rect.y, - left: rect.x, - bottom: rect.height + rect.y, - right: rect.width + rect.x, - ...rect, - }), - }); -}; - beforeEach(() => { mockBoundingClientRect(document.body, { width: 0, diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts index 6d5d98a..2336a37 100644 --- a/src/__tests__/utils.ts +++ b/src/__tests__/utils.ts @@ -8,3 +8,28 @@ export const escapeRegexString = (input: string) => input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); export const containsRegex = (input: string) => new RegExp(`${escapeRegexString(input)}`); + +/** + * Create a mock that sets the real values for the element coordinates + * because jsdom does not actually modify element coordinates + */ +export const mockBoundingClientRect = ( + element: HTMLElement, + rect: { + width: number; + height: number; + x: number; + y: number; + }, +) => { + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + top: rect.y, + left: rect.x, + bottom: rect.height + rect.y, + right: rect.width + rect.x, + ...rect, + }), + }); +}; From aad9b34b1b6df41c831a491bbc958aa84afdca4f Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 18 May 2025 02:29:32 +0200 Subject: [PATCH 179/313] test: improve test case --- src/__tests__/TranslationDispatcher.test.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 5db7945..413a5d7 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -2,7 +2,13 @@ import { DOMNodesTranslator } from '../DOMNodesTranslator'; import { NodesIntersectionObserver } from '../NodesIntersectionObserver'; import { TranslationDispatcher } from '../TranslationDispatcher'; import { configureTranslatableNodePredicate } from '../utils/nodes'; -import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; +import { + awaitTranslation, + containsRegex, + mockBoundingClientRect, + TRANSLATION_SYMBOL, + translator, +} from './utils'; require('intersection-observer'); @@ -13,11 +19,10 @@ beforeEach(() => { const isTranslatableNode = () => true; -test('Translate node that is not suitable for delayed translation', async () => { - const domNodesTranslator = new DOMNodesTranslator(translator); +test('Node translates immediately in lazy-translation mode if it is not intersectable', async () => { const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, - nodeTranslator: domNodesTranslator, + nodeTranslator: new DOMNodesTranslator(translator), nodeIntersectionObserver: new NodesIntersectionObserver(), }); @@ -28,9 +33,15 @@ test('Translate node that is not suitable for delayed translation', async () => select.appendChild(option); document.body.appendChild(select); + mockBoundingClientRect(document.body, { width: 100, height: 200, x: 0, y: 0 }); + // options not intersect viewport + // IntersectionObserver should not invoke the callback until the node appears in the viewport + mockBoundingClientRect(option, { width: 50, height: 100, x: 0, y: 300 }); + translationDispatcher.translateNode(select); await awaitTranslation(); + // element is translated regardless of its viewport intersection expect(option.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); From 6c0ced2bdc7ff45bba7bd7481e08988bf2288cd6 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 18 May 2025 02:30:13 +0200 Subject: [PATCH 180/313] chore: delete comment --- src/DefaultNodesTranslator.ts | 1 - src/__tests__/NodesTranslator.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index fc48de2..7c4f6ce 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -26,7 +26,6 @@ export class DefaultNodesTranslator extends NodesTranslator { const domNodesTranslator = new DOMNodesTranslator(translateCallback); - // not create instance if param lazyTranslate falsy const nodeIntersectionObserver = lazyTranslate ? new NodesIntersectionObserver() : undefined; diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 3fb8d48..7292c89 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -41,7 +41,6 @@ function buildTranslationServices( const domNodesTranslator = new DOMNodesTranslator(translateCallback); - // not create instance if param lazyTranslate falsy const nodeIntersectionObserver = config.lazyTranslate ? new NodesIntersectionObserver() : undefined; From d15f9acfdde8a9724357ca9a5544ae960726ae0f Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 18 May 2025 22:26:36 +0200 Subject: [PATCH 181/313] fix: unreachable condition, fix typo --- src/NodesIntersectionObserver.ts | 44 +++++++++++++++----------------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/src/NodesIntersectionObserver.ts b/src/NodesIntersectionObserver.ts index d50f0d2..21bb2e2 100644 --- a/src/NodesIntersectionObserver.ts +++ b/src/NodesIntersectionObserver.ts @@ -16,7 +16,8 @@ type Callback = (node: Node) => void; export class NodesIntersectionObserver { private readonly intersectionObserver: IntersectionObserver; private readonly nodeCallbacksMap = new WeakMap(); - // Stores the nodes and his owner element that is under observing for intersection + + // Stores nodes and their owner element that are being observed for intersection private readonly elementNodesMap = new WeakMap>(); constructor(intersectionConfig?: IntersectionObserverInit) { @@ -29,8 +30,8 @@ export class NodesIntersectionObserver { this.triggerNestedNodes(node); - // Process the element once and forget it - // This makes it possible to observe the element again later if needed + // Process the element once and stop observing it + // This allows re-observing the element later if needed this.elementNodesMap.delete(node); this.nodeCallbacksMap.delete(node); observer.unobserve(node); @@ -46,28 +47,25 @@ export class NodesIntersectionObserver { public observe(node: Node, callback: Callback) { const ownerElement = getElementOfNode(node); - // immediately invoke callback if node has no owner or is not intersectable + // Immediately invoke the callback if the node has no owner or is not intersectable if (!ownerElement || !isIntersectableNode(ownerElement)) { callback(node); return; } - // add node to set only if not exist yet + // set the callback for the node + this.nodeCallbacksMap.set(node, callback); + + // add ownerElement if it doesn't exist in the map const observedNodes = this.elementNodesMap.get(ownerElement); - if (observedNodes) { - // set callback for node - this.nodeCallbacksMap.set(node, callback); - - const isNodeExist = observedNodes.has(node); - if (!isNodeExist) { - observedNodes.add(node); - } + if (!observedNodes) { + this.elementNodesMap.set(ownerElement, new Set().add(node)); + this.intersectionObserver.observe(ownerElement); return; } - this.elementNodesMap.set(ownerElement, new Set().add(node)); - this.nodeCallbacksMap.set(node, callback); - this.intersectionObserver.observe(ownerElement); + // add the node to the set of observed nodes + observedNodes.add(node); } /** @@ -77,19 +75,17 @@ export class NodesIntersectionObserver { const ownerElement = getElementOfNode(node); if (!ownerElement) return; const observedNodes = this.elementNodesMap.get(ownerElement); - if (!observedNodes) return; + if (!observedNodes || !observedNodes.has(node)) return; + // delete only the specified node + observedNodes.delete(node); + this.nodeCallbacksMap.delete(node); + + // if no more nodes are tracked under this ownerElement, stop observing it if (observedNodes.size === 0) { - // if no more nodes are tracked under this ownerElement, stop observing the ownerElement this.elementNodesMap.delete(ownerElement); this.nodeCallbacksMap.delete(node); this.intersectionObserver.unobserve(ownerElement); - } else { - // delete only the received node - if (!observedNodes.has(node)) return; - - observedNodes.delete(node); - this.nodeCallbacksMap.delete(node); } } From 3f96f24317f68fb8077bfeb71f0f8eda270290bc Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 18 May 2025 22:33:39 +0200 Subject: [PATCH 182/313] refactor: delete duplicate code --- src/NodesIntersectionObserver.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/NodesIntersectionObserver.ts b/src/NodesIntersectionObserver.ts index 21bb2e2..d57bafc 100644 --- a/src/NodesIntersectionObserver.ts +++ b/src/NodesIntersectionObserver.ts @@ -84,7 +84,6 @@ export class NodesIntersectionObserver { // if no more nodes are tracked under this ownerElement, stop observing it if (observedNodes.size === 0) { this.elementNodesMap.delete(ownerElement); - this.nodeCallbacksMap.delete(node); this.intersectionObserver.unobserve(ownerElement); } } From ae0ed03bdc1331198dc51fb0449a788590d78df2 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 19 May 2025 00:05:53 +0200 Subject: [PATCH 183/313] chore: fix typo --- src/__tests__/DOMNodesTranslator.test.ts | 28 ++++++------ .../NodesIntersectionObserver.test.ts | 43 +++++++++---------- src/__tests__/TranslationDispatcher.test.ts | 29 +++++-------- 3 files changed, 46 insertions(+), 54 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index d86df38..fff2cb8 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -1,7 +1,7 @@ import { DOMNodesTranslator } from '../DOMNodesTranslator'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; -test('Translate and restore original node text', async () => { +test('Translates a node and restores the original node text', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const nodeText = 'Hello world!'; @@ -23,28 +23,28 @@ test('Stores original text on translation and clears it on restoration', async ( const div = document.createElement('div'); div.textContent = nodeText; - // node not translated, original text is null + // before translation expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); - // node has been translated expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toEqual(nodeText); - // reset translation + // after restore domNodesTranslator.restoreNode(div.childNodes[0]); expect(div.textContent).toBe(nodeText); expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); }); -test('Translated node exist in the storage', async () => { +test('Stores node during translation and removes it upon restoration', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const div = document.createElement('div'); const nodeText = 'Hello world!'; div.textContent = nodeText; + // not exists before translate expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(false); @@ -58,51 +58,51 @@ test('Translated node exist in the storage', async () => { expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(false); }); -test('Translate the node after updating its text', async () => { +test('Translates the attribute node text after its value is changed', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const node = document.createElement('a'); node.setAttribute('title', 'title text'); - // translate element + // translate the attribute node domNodesTranslator.translateNode(node.attributes[0]); await awaitTranslation(); expect(node.attributes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - // the first call updateNode will update the updateId state, but the node won’t be translated - // because the internal state updateId will be equal to translateContext, this approach prevent recursion translation + // the first call to updateNode updates the internal updateId state, + // but the node won't be translated immediately because the updateId matches the translate context. + // this prevents recursive translation calls. const text1 = 'title text is update'; node.setAttribute('title', text1); domNodesTranslator.updateNode(node.attributes[0]); await awaitTranslation(); - // this call will translate node text + // the second call to updateNode triggers the actual translation of the node text domNodesTranslator.updateNode(node.attributes[0]); await awaitTranslation(); expect(node.attributes[0].textContent).toMatch(text1); expect(node.attributes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Restored node contains the most recent content after several translations', async () => { +test('Restores the most recent original text after multiple translations', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const div = document.createElement('div'); const nodeText = 'Hello world!'; div.textContent = nodeText; - // translate domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - // translate again with changed text + // change text const nodeText1 = 'My name is Jake'; div.textContent = nodeText1; domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - // restore, elements have the last updated text and have not translated + // restore: elements have the last updated text and are not translated domNodesTranslator.restoreNode(div.childNodes[0]); expect(div.textContent).toBe(nodeText1); }); diff --git a/src/__tests__/NodesIntersectionObserver.test.ts b/src/__tests__/NodesIntersectionObserver.test.ts index d84182b..9193468 100644 --- a/src/__tests__/NodesIntersectionObserver.test.ts +++ b/src/__tests__/NodesIntersectionObserver.test.ts @@ -23,7 +23,7 @@ beforeEach(() => { vi.clearAllMocks(); }); -test('Calls callback for node from viewport', async () => { +test('Triggers callback for node in viewport', async () => { const div = document.createElement('div'); div.textContent = 'Hello, World!'; document.body.appendChild(div); @@ -38,11 +38,11 @@ test('Calls callback for node from viewport', async () => { expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Calls callback for a node only when it becomes intersectable', async () => { +test('Triggers callback for a node only when it becomes intersectable', async () => { const lazyTranslator = new NodesIntersectionObserver(); // node with display = 'none' is not intersectable - // node with the visible='hidden' property is considered intersectable, so use the display=none property instead + // node with visibility: 'hidden' is considered intersectable, so use display: 'none' instead const div = document.createElement('div'); div.textContent = 'Hello, world'; div.style.display = 'none'; @@ -62,10 +62,10 @@ test('Calls callback for a node only when it becomes intersectable', async () => expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Not calls callback after node is detached', async () => { +test('Does not trigger callback after node is detached', async () => { const lazyTranslator = new NodesIntersectionObserver(); - // create node with display=none, it not intersectible + // node with display: none is not intersectable const div = document.createElement('div'); div.textContent = 'Hello world!'; div.style.display = 'none'; @@ -74,21 +74,21 @@ test('Not calls callback after node is detached', async () => { lazyTranslator.observe(div.childNodes[0], translator); await awaitTranslation(); - // not translate because node not visible + // does not translate because node is not visible expect(translator.mock.calls).toEqual([]); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); // node is detached lazyTranslator.unobserve(div.childNodes[0]); - // becomes visible and intersectable, but is still not translated after detach + + // becomes visible and intersectable, but still does not translate after being detached div.style.display = 'block'; await awaitTranslation(); - expect(translator.mock.calls).toEqual([]); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Calls callback only after node intersect viewport', async () => { +test('Triggers callback only after node intersects viewport', async () => { const lazyTranslator = new NodesIntersectionObserver(); const div = document.createElement('div'); div.textContent = 'Hello world!'; @@ -101,7 +101,7 @@ test('Calls callback only after node intersect viewport', async () => { y: 0, }); - // element out of viewport, it not intersect container + // element is outside the viewport and does not intersect the container mockBoundingClientRect(div, { width: 100, height: 100, @@ -112,11 +112,11 @@ test('Calls callback only after node intersect viewport', async () => { lazyTranslator.observe(div.childNodes[0], translator); await awaitTranslation(); - // don't translate because the node doesn't intersect the container + // does not translate because the node does not intersect the container expect(translator.mock.calls).toEqual([]); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - // change coordinates, now node in viewport + // change coordinates, the node is now inside the viewport mockBoundingClientRect(div, { width: 100, height: 100, @@ -124,16 +124,15 @@ test('Calls callback only after node intersect viewport', async () => { y: 0, }); - // simulates the scroll event; polyfill listens for the "scroll" event on the document - // The polyfill will start recalculating the element position to find intersections only after the event + // simulate a scroll event; the polyfill listens for the "scroll" event on the document + // The polyfill starts recalculating element positions only after the event document.dispatchEvent(new Event('scroll')); await awaitTranslation(); - expect(translator.mock.calls).toEqual([[div.childNodes[0]]]); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Not calls a callback for node that not intersect viewport after scrolling', async () => { +test('Does not triggers callback for node that does not intersect viewport after scrolling', async () => { const lazyTranslator = new NodesIntersectionObserver(); const div = document.createElement('div'); div.textContent = 'Hello world!'; @@ -146,7 +145,7 @@ test('Not calls a callback for node that not intersect viewport after scrolling' y: 0, }); - // node out of viewport, it not intersect container + // node is outside the viewport and does not intersect the container mockBoundingClientRect(div, { width: 100, height: 100, @@ -157,11 +156,11 @@ test('Not calls a callback for node that not intersect viewport after scrolling' lazyTranslator.observe(div.childNodes[0], translator); await awaitTranslation(); - // don't translate because the element doesn't intersect the container + // does not translate because the element does not intersect the container expect(translator.mock.calls).toEqual([]); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); - // change coordinates, node still not in viewport + // change coordinates, the node is still outside the viewport mockBoundingClientRect(div, { width: 100, height: 100, @@ -169,12 +168,12 @@ test('Not calls a callback for node that not intersect viewport after scrolling' y: 330, }); - // simulates the scroll event; polyfill listens for the "scroll" event on the document - // The polyfill will start recalculating the element position to find intersections only after the event + // simulate a scroll event; the polyfill listens for the "scroll" event on the document + // The polyfill starts recalculating element positions only after the event document.dispatchEvent(new Event('scroll')); await awaitTranslation(); - // still have not translate + // still not translated expect(translator.mock.calls).toEqual([]); expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); }); diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 413a5d7..a4bc33f 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -19,33 +19,32 @@ beforeEach(() => { const isTranslatableNode = () => true; -test('Node translates immediately in lazy-translation mode if it is not intersectable', async () => { +test('In lazy-translation mode a non-intersecting node translates immediately', async () => { const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodeTranslator: new DOMNodesTranslator(translator), nodeIntersectionObserver: new NodesIntersectionObserver(), }); - // OPTION node is not intersectable, node can`t translate latter + // OPTION node is not intersectable; it cannot be translated later const select = document.createElement('select'); const option = document.createElement('option'); option.textContent = 'Hello, world!'; select.appendChild(option); document.body.appendChild(select); - mockBoundingClientRect(document.body, { width: 100, height: 200, x: 0, y: 0 }); // options not intersect viewport // IntersectionObserver should not invoke the callback until the node appears in the viewport mockBoundingClientRect(option, { width: 50, height: 100, x: 0, y: 300 }); + mockBoundingClientRect(document.body, { width: 100, height: 200, x: 0, y: 0 }); + // the element is translated regardless of viewport intersection translationDispatcher.translateNode(select); await awaitTranslation(); - - // element is translated regardless of its viewport intersection expect(option.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('Translate node not attached to DOM', async () => { +test('In lazy-translation mode a node not attached to the body translates immediately', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, @@ -53,7 +52,8 @@ test('Translate node not attached to DOM', async () => { nodeIntersectionObserver: new NodesIntersectionObserver(), }); - // this node not attached to DOM, but should be translated + // the node is not in document.body, it is not intersecteble and cannot be translated later. + // translation must happen immediately const head = document.createElement('head'); const title = document.createElement('title'); const text = 'Title can contain only text'; @@ -63,10 +63,6 @@ test('Translate node not attached to DOM', async () => { translationDispatcher.translateNode(head); await awaitTranslation(); expect(title.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - - // restore - translationDispatcher.restoreNode(head); - expect(title.textContent).toBe(text); }); test('Translates and restores the element and its child elements', async () => { @@ -79,15 +75,14 @@ test('Translates and restores the element and its child elements', async () => { div.appendChild(div1); document.body.appendChild(div); - const domNodesTranslator = new DOMNodesTranslator(translator); const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, - nodeTranslator: domNodesTranslator, + nodeTranslator: new DOMNodesTranslator(translator), }); translationDispatcher.translateNode(div); await awaitTranslation(); - // check text on the element itself + // check the text on the element itself expect(div.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(div1.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); @@ -97,15 +92,13 @@ test('Translates and restores the element and its child elements', async () => { expect(div1.childNodes[0].textContent).toBe(text1); }); -test('Do not translate ignored node inside element', async () => { +test('Does not translate ignored node', async () => { const filter = configureTranslatableNodePredicate({ ignoredSelectors: ['comment'], }); - const nodeTranslator = new DOMNodesTranslator(translator); const translationDispatcher = new TranslationDispatcher({ filter, - nodeTranslator, - nodeIntersectionObserver: new NodesIntersectionObserver(), + nodeTranslator: new DOMNodesTranslator(translator), }); const div = document.createElement('div'); From 09fdefc8c134257841adfa3cc465e515eda8a624 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 19 May 2025 00:10:36 +0200 Subject: [PATCH 184/313] chore: fix typo --- src/NodesIntersectionObserver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NodesIntersectionObserver.ts b/src/NodesIntersectionObserver.ts index d57bafc..490359b 100644 --- a/src/NodesIntersectionObserver.ts +++ b/src/NodesIntersectionObserver.ts @@ -11,7 +11,7 @@ type Callback = (node: Node) => void; /** * Observes DOM nodes for intersection with the viewport and triggers callbacks when they become visible. - * WARNING: Class works with nodes, not elements directly + * WARNING: This class works with nodes (Text, Attr, etc.), not directly with elements. */ export class NodesIntersectionObserver { private readonly intersectionObserver: IntersectionObserver; From efe93c75b14ce300b783fe8a0ea733fa38086655 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 19 May 2025 00:23:15 +0200 Subject: [PATCH 185/313] fix: incorrect cleaning storage --- src/NodesIntersectionObserver.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/NodesIntersectionObserver.ts b/src/NodesIntersectionObserver.ts index 490359b..137f78c 100644 --- a/src/NodesIntersectionObserver.ts +++ b/src/NodesIntersectionObserver.ts @@ -33,7 +33,6 @@ export class NodesIntersectionObserver { // Process the element once and stop observing it // This allows re-observing the element later if needed this.elementNodesMap.delete(node); - this.nodeCallbacksMap.delete(node); observer.unobserve(node); }); }, intersectionConfig); @@ -89,13 +88,15 @@ export class NodesIntersectionObserver { } /** - * Calls callbacks for all observed nodes associated with the specified element + * Calls callbacks for all observed nodes associated with the specified element and removes their callbacks from storage */ private triggerNestedNodes(node: Element) { const ownedNodes = this.elementNodesMap.get(node); ownedNodes?.forEach((node) => { const callback = this.nodeCallbacksMap.get(node); if (callback) callback(node); + + this.nodeCallbacksMap.delete(node); }); } } From 13672269e7f8c4ef0fb6b32f8adac770507ee78e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 19 May 2025 00:39:19 +0200 Subject: [PATCH 186/313] chore: remove await --- src/__tests__/TranslationDispatcher.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index a4bc33f..d1b4cdf 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -87,9 +87,8 @@ test('Translates and restores the element and its child elements', async () => { expect(div1.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); translationDispatcher.restoreNode(div); - await awaitTranslation(); - expect(div.childNodes[0].textContent).toBe(text); - expect(div1.childNodes[0].textContent).toBe(text1); + expect(div.textContent).toBe(text); + expect(div1.textContent).toBe(text1); }); test('Does not translate ignored node', async () => { From b46ba9a0fcacc97e6c656c22ea35f66d7dd2f020 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 19 May 2025 00:43:19 +0200 Subject: [PATCH 187/313] test: delete unnecessary --- src/__tests__/TranslationDispatcher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index d1b4cdf..85b4018 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -13,7 +13,7 @@ import { require('intersection-observer'); beforeEach(() => { - vi.clearAllMocks(); + mockBoundingClientRect(document.body, { width: 0, height: 0, x: 0, y: 0 }); document.body.innerHTML = ''; }); From 2aadab1868e531dd44ee6473d4ac05694c1cc43b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 19 May 2025 00:54:54 +0200 Subject: [PATCH 188/313] test: improve checks --- src/__tests__/DOMNodesTranslator.test.ts | 10 ++++++---- src/__tests__/TranslationDispatcher.test.ts | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index fff2cb8..854ab85 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -62,20 +62,22 @@ test('Translates the attribute node text after its value is changed', async () = const domNodesTranslator = new DOMNodesTranslator(translator); const node = document.createElement('a'); - node.setAttribute('title', 'title text'); + const text = 'title text'; + node.setAttribute('title', text); // translate the attribute node domNodesTranslator.translateNode(node.attributes[0]); await awaitTranslation(); expect(node.attributes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - // the first call to updateNode updates the internal updateId state, - // but the node won't be translated immediately because the updateId matches the translate context. - // this prevents recursive translation calls. + // the first call to updateNode updates the internal updateId state + // but the node won't be translated immediately because the updateId matches the translate context + // this prevents recursive translation calls const text1 = 'title text is update'; node.setAttribute('title', text1); domNodesTranslator.updateNode(node.attributes[0]); await awaitTranslation(); + expect(node.attributes[0].textContent).toBe(text1); // the second call to updateNode triggers the actual translation of the node text domNodesTranslator.updateNode(node.attributes[0]); diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 85b4018..7c784d2 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -87,8 +87,8 @@ test('Translates and restores the element and its child elements', async () => { expect(div1.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); translationDispatcher.restoreNode(div); - expect(div.textContent).toBe(text); - expect(div1.textContent).toBe(text1); + expect(div.childNodes[0].textContent).toBe(text); + expect(div1.childNodes[0].textContent).toBe(text1); }); test('Does not translate ignored node', async () => { From 8aaf20c6cdaec9f664c3673536931b3daa05f5c5 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 20 May 2025 16:02:25 +0200 Subject: [PATCH 189/313] refactor: move the recursion prevention mechanism --- src/DOMNodesTranslator.ts | 5 --- src/NodesTranslator.ts | 56 ++++++++++++++++++++---- src/__tests__/DOMNodesTranslator.test.ts | 15 ++----- 3 files changed, 51 insertions(+), 25 deletions(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 3e0aba8..196f8af 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -130,11 +130,6 @@ export class DOMNodesTranslator { if (node.nodeValue === null) return; - // Recursion prevention - if (nodeData.updateId <= nodeData.translateContext) { - return; - } - const nodeId = nodeData.id; const nodeContext = nodeData.updateId; return this.translateCallback(node.nodeValue, nodeData.priority).then((text) => { diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index a72afa9..247791a 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -24,6 +24,27 @@ export class NodesTranslator { this.nodeTranslator = nodeTranslator; } + private readonly expected = new WeakMap(); + + private callHandler = (node: Node, callback: (node: Node) => void) => { + const actualValue = node instanceof Attr ? node.value : node.nodeValue; + const exp = this.getValue(node); + + if (exp !== undefined && actualValue == exp) { + this.expected.set(node, null); + return; + } + + callback(node); + + this.setValue(node, node.nodeValue); + }; + + private setValue = (node: Node, value: string | null) => + this.expected.set(node, value); + private getValue = (node: Node) => this.expected.get(node); + private deleteValue = (node: Node) => this.expected.delete(node); + private readonly observedNodesStorage = new Map(); public observe(node: Element) { if (this.observedNodesStorage.has(node)) { @@ -34,14 +55,22 @@ export class NodesTranslator { const observer = new XMutationObserver(); this.observedNodesStorage.set(node, observer); - observer.addHandler('elementAdded', ({ target }) => - this.dispatcher.translateNode(target), - ); - observer.addHandler('elementRemoved', ({ target }) => - this.dispatcher.restoreNode(target), - ); + observer.addHandler('elementAdded', ({ target }) => { + this.callHandler(target, () => { + this.dispatcher.translateNode(target); + }); + }); + observer.addHandler('elementRemoved', ({ target }) => { + this.deleteValue(target); + this.callHandler(target, () => { + this.dispatcher.restoreNode(target); + }); + }); observer.addHandler('characterData', ({ target }) => { - this.dispatcher.updateNode(target); + this.setValue(target, target.nodeValue); + this.callHandler(target, () => { + this.dispatcher.updateNode(target); + }); }); observer.addHandler('changeAttribute', ({ target, attributeName }) => { if (attributeName === undefined || attributeName === null) return; @@ -51,15 +80,24 @@ export class NodesTranslator { if (attribute === null) return; + if (this.getValue(attribute) !== null) { + this.setValue(attribute, attribute.value); + } + // NOTE: If need delete untracked nodes, we should keep relates like Element -> attributes if (!this.dispatcher.hasNode(attribute)) { - this.dispatcher.translateNode(attribute); + this.callHandler(attribute, () => { + this.dispatcher.translateNode(attribute); + }); } else { - this.dispatcher.updateNode(attribute); + this.callHandler(attribute, () => { + this.dispatcher.updateNode(attribute); + }); } }); observer.observe(node); + this.dispatcher.translateNode(node); } diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 854ab85..e6a08a2 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -58,32 +58,25 @@ test('Stores node during translation and removes it upon restoration', async () expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(false); }); -test('Translates the attribute node text after its value is changed', async () => { +test('Updates translation when attribute value changes', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const node = document.createElement('a'); const text = 'title text'; node.setAttribute('title', text); - // translate the attribute node + // translate domNodesTranslator.translateNode(node.attributes[0]); await awaitTranslation(); expect(node.attributes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - // the first call to updateNode updates the internal updateId state - // but the node won't be translated immediately because the updateId matches the translate context - // this prevents recursive translation calls + // update value const text1 = 'title text is update'; node.setAttribute('title', text1); domNodesTranslator.updateNode(node.attributes[0]); await awaitTranslation(); - expect(node.attributes[0].textContent).toBe(text1); - - // the second call to updateNode triggers the actual translation of the node text - domNodesTranslator.updateNode(node.attributes[0]); - await awaitTranslation(); - expect(node.attributes[0].textContent).toMatch(text1); expect(node.attributes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(node.attributes[0].textContent).toMatch(text); }); test('Restores the most recent original text after multiple translations', async () => { From 04ff4148fdaf9f9dc28ef30473e632e218b39080 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 20 May 2025 17:25:57 +0200 Subject: [PATCH 190/313] chore: rename, add docs --- src/NodesTranslator.ts | 45 ++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 247791a..cda68f5 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -24,26 +24,37 @@ export class NodesTranslator { this.nodeTranslator = nodeTranslator; } - private readonly expected = new WeakMap(); - + /** + * Stores the last value of a node (text or attribute). Used to detect recursive processing triggered by previous translations. + * A `null` node value indicates that the last change was intentional, + * and the next change should trigger processing again + */ + private lastNodesValue: WeakMap | undefined = new WeakMap(); + + /** + * Executes the provided callback only if the node's current value differs from the previous value. + * This prevents recursive translation. + */ private callHandler = (node: Node, callback: (node: Node) => void) => { - const actualValue = node instanceof Attr ? node.value : node.nodeValue; - const exp = this.getValue(node); + const actualValue = node.nodeValue; + const expectedValue = this.getLastNodeValue(node); - if (exp !== undefined && actualValue == exp) { - this.expected.set(node, null); + // If the value hasn't changed, skip the callback and clear the store + if (expectedValue !== undefined && actualValue === expectedValue) { + this.setLastNodeValue(node, null); return; } callback(node); - this.setValue(node, node.nodeValue); + // Save the new value for future change detection + this.setLastNodeValue(node, node.nodeValue); }; - private setValue = (node: Node, value: string | null) => - this.expected.set(node, value); - private getValue = (node: Node) => this.expected.get(node); - private deleteValue = (node: Node) => this.expected.delete(node); + private setLastNodeValue = (node: Node, value: string | null) => + this.lastNodesValue?.set(node, value); + private getLastNodeValue = (node: Node) => this.lastNodesValue?.get(node); + private deleteLastNodeValue = (node: Node) => this.lastNodesValue?.delete(node); private readonly observedNodesStorage = new Map(); public observe(node: Element) { @@ -61,13 +72,14 @@ export class NodesTranslator { }); }); observer.addHandler('elementRemoved', ({ target }) => { - this.deleteValue(target); + this.deleteLastNodeValue(target); this.callHandler(target, () => { this.dispatcher.restoreNode(target); }); }); observer.addHandler('characterData', ({ target }) => { - this.setValue(target, target.nodeValue); + console.log('characterData', target.nodeName, target.nodeValue); + this.setLastNodeValue(target, target.nodeValue); this.callHandler(target, () => { this.dispatcher.updateNode(target); }); @@ -80,8 +92,9 @@ export class NodesTranslator { if (attribute === null) return; - if (this.getValue(attribute) !== null) { - this.setValue(attribute, attribute.value); + // If the value is null, it means the node has just been processed, and we should only handle the next changes. + if (this.getLastNodeValue(attribute) !== null) { + this.setLastNodeValue(attribute, attribute.value); } // NOTE: If need delete untracked nodes, we should keep relates like Element -> attributes @@ -90,6 +103,7 @@ export class NodesTranslator { this.dispatcher.translateNode(attribute); }); } else { + console.log('changeAttribute update', attribute.name, attribute.value); this.callHandler(attribute, () => { this.dispatcher.updateNode(attribute); }); @@ -109,6 +123,7 @@ export class NodesTranslator { this.dispatcher.restoreNode(node); this.observedNodesStorage.get(node)?.disconnect(); this.observedNodesStorage.delete(node); + // this.lastNodesValue = null; } public getNodeData(node: Node) { From ea61519d34a7690cae022ee04559e8ccd85f4928 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 20 May 2025 17:29:14 +0200 Subject: [PATCH 191/313] chore: remove console --- src/NodesTranslator.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index cda68f5..6a412f4 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -29,7 +29,7 @@ export class NodesTranslator { * A `null` node value indicates that the last change was intentional, * and the next change should trigger processing again */ - private lastNodesValue: WeakMap | undefined = new WeakMap(); + private lastNodesValue = new WeakMap(); /** * Executes the provided callback only if the node's current value differs from the previous value. @@ -78,7 +78,6 @@ export class NodesTranslator { }); }); observer.addHandler('characterData', ({ target }) => { - console.log('characterData', target.nodeName, target.nodeValue); this.setLastNodeValue(target, target.nodeValue); this.callHandler(target, () => { this.dispatcher.updateNode(target); @@ -103,7 +102,6 @@ export class NodesTranslator { this.dispatcher.translateNode(attribute); }); } else { - console.log('changeAttribute update', attribute.name, attribute.value); this.callHandler(attribute, () => { this.dispatcher.updateNode(attribute); }); @@ -123,7 +121,6 @@ export class NodesTranslator { this.dispatcher.restoreNode(node); this.observedNodesStorage.get(node)?.disconnect(); this.observedNodesStorage.delete(node); - // this.lastNodesValue = null; } public getNodeData(node: Node) { From d3c27ebfb08eb04df3ae57e719351b600503591a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 20 May 2025 18:10:19 +0200 Subject: [PATCH 192/313] refactor: clear storage --- src/NodesTranslator.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 6a412f4..fbc7f7a 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,5 +1,6 @@ import { XMutationObserver } from './lib/XMutationObserver'; import { TranslationDispatcher } from './TranslationDispatcher'; +import { visitWholeTree } from './utils/visitWholeTree'; import { DOMNodesTranslator } from '.'; // TODO: consider local language definitions (and implement `from`, `to` parameters for translator to specify default or locale languages) @@ -55,6 +56,15 @@ export class NodesTranslator { this.lastNodesValue?.set(node, value); private getLastNodeValue = (node: Node) => this.lastNodesValue?.get(node); private deleteLastNodeValue = (node: Node) => this.lastNodesValue?.delete(node); + private clearLastNodesValueStorage(node: Element) { + visitWholeTree(node, () => { + if (node instanceof Element) return; + this.clearLastNodesValueStorage(node); + }); + if (this.lastNodesValue.has(node)) { + this.deleteLastNodeValue(node); + } + } private readonly observedNodesStorage = new Map(); public observe(node: Element) { @@ -121,6 +131,7 @@ export class NodesTranslator { this.dispatcher.restoreNode(node); this.observedNodesStorage.get(node)?.disconnect(); this.observedNodesStorage.delete(node); + this.clearLastNodesValueStorage(node); } public getNodeData(node: Node) { From 81e1f2cc6396b2ff2a71c96519bb52bbefd0e6bb Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 20 May 2025 18:24:48 +0200 Subject: [PATCH 193/313] refactor: remove property for prevent recursion --- src/DOMNodesTranslator.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 196f8af..6561eaa 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -13,11 +13,6 @@ interface NodeData { */ updateId: number; - /** - * Contains `updateId` value at time when start node translation - */ - translateContext: number; - /** * Original node text, before start translation * Contains `null` for node that not been translated yet @@ -86,7 +81,6 @@ export class DOMNodesTranslator { this.nodeStorage.set(node, { id: this.idCounter++, updateId: 1, - translateContext: 0, originalText: null, priority: getNodePriority(node), }); @@ -142,7 +136,6 @@ export class DOMNodesTranslator { } actualNodeData.originalText = node.nodeValue !== null ? node.nodeValue : ''; - actualNodeData.translateContext = actualNodeData.updateId + 1; node.nodeValue = text; }); } From b772ed070add37098b01487576b3ff0f427c1d41 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 20 May 2025 18:39:10 +0200 Subject: [PATCH 194/313] chore: fix style and typo --- src/NodesTranslator.ts | 84 ++++++++++++++++++++---------------------- 1 file changed, 40 insertions(+), 44 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index fbc7f7a..2a8475b 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -8,7 +8,7 @@ import { DOMNodesTranslator } from '.'; // TODO: describe nodes life cycle /** - * Module for dynamic translate a DOM nodes + * Module for dynamic translate a DOM nodes. */ export class NodesTranslator { private readonly dispatcher; @@ -26,46 +26,11 @@ export class NodesTranslator { } /** - * Stores the last value of a node (text or attribute). Used to detect recursive processing triggered by previous translations. - * A `null` node value indicates that the last change was intentional, - * and the next change should trigger processing again + * Stores the last node value to prevent redundant reprocessing. + * node value `null` marks intentional changes that shouldn't trigger processing. */ private lastNodesValue = new WeakMap(); - /** - * Executes the provided callback only if the node's current value differs from the previous value. - * This prevents recursive translation. - */ - private callHandler = (node: Node, callback: (node: Node) => void) => { - const actualValue = node.nodeValue; - const expectedValue = this.getLastNodeValue(node); - - // If the value hasn't changed, skip the callback and clear the store - if (expectedValue !== undefined && actualValue === expectedValue) { - this.setLastNodeValue(node, null); - return; - } - - callback(node); - - // Save the new value for future change detection - this.setLastNodeValue(node, node.nodeValue); - }; - - private setLastNodeValue = (node: Node, value: string | null) => - this.lastNodesValue?.set(node, value); - private getLastNodeValue = (node: Node) => this.lastNodesValue?.get(node); - private deleteLastNodeValue = (node: Node) => this.lastNodesValue?.delete(node); - private clearLastNodesValueStorage(node: Element) { - visitWholeTree(node, () => { - if (node instanceof Element) return; - this.clearLastNodesValueStorage(node); - }); - if (this.lastNodesValue.has(node)) { - this.deleteLastNodeValue(node); - } - } - private readonly observedNodesStorage = new Map(); public observe(node: Element) { if (this.observedNodesStorage.has(node)) { @@ -77,19 +42,19 @@ export class NodesTranslator { this.observedNodesStorage.set(node, observer); observer.addHandler('elementAdded', ({ target }) => { - this.callHandler(target, () => { + this.processNodeChanges(target, () => { this.dispatcher.translateNode(target); }); }); observer.addHandler('elementRemoved', ({ target }) => { this.deleteLastNodeValue(target); - this.callHandler(target, () => { + this.processNodeChanges(target, () => { this.dispatcher.restoreNode(target); }); }); observer.addHandler('characterData', ({ target }) => { this.setLastNodeValue(target, target.nodeValue); - this.callHandler(target, () => { + this.processNodeChanges(target, () => { this.dispatcher.updateNode(target); }); }); @@ -101,18 +66,19 @@ export class NodesTranslator { if (attribute === null) return; - // If the value is null, it means the node has just been processed, and we should only handle the next changes. + // If the value is null, it means the node has just been processed, + // we should only handle the next changes if (this.getLastNodeValue(attribute) !== null) { this.setLastNodeValue(attribute, attribute.value); } // NOTE: If need delete untracked nodes, we should keep relates like Element -> attributes if (!this.dispatcher.hasNode(attribute)) { - this.callHandler(attribute, () => { + this.processNodeChanges(attribute, () => { this.dispatcher.translateNode(attribute); }); } else { - this.callHandler(attribute, () => { + this.processNodeChanges(attribute, () => { this.dispatcher.updateNode(attribute); }); } @@ -137,4 +103,34 @@ export class NodesTranslator { public getNodeData(node: Node) { return this.nodeTranslator.getOriginalNodeText(node); } + + // Runs callback only if node value has changed to prevent recursion. + private processNodeChanges = (node: Node, callback: (node: Node) => void) => { + const actualValue = node.nodeValue; + const expectedValue = this.getLastNodeValue(node); + + // If the value has not changed, skip the callback + if (expectedValue !== undefined && actualValue === expectedValue) { + this.setLastNodeValue(node, null); + return; + } + callback(node); + + // save the new value for future change detection + this.setLastNodeValue(node, node.nodeValue); + }; + + private setLastNodeValue = (node: Node, value: string | null) => + this.lastNodesValue.set(node, value); + private getLastNodeValue = (node: Node) => this.lastNodesValue.get(node); + private deleteLastNodeValue = (node: Node) => this.lastNodesValue.delete(node); + + // removes all saved values for the given element + private clearLastNodesValueStorage(node: Element) { + visitWholeTree(node, () => { + if (node instanceof Element) return; + this.clearLastNodesValueStorage(node); + }); + this.deleteLastNodeValue(node); + } } From 19bf945b2891c6d839a605f0e5629fced1345997 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 22 May 2025 23:25:38 +0200 Subject: [PATCH 195/313] test: add --- .../NodesTranslator.preventRecursion.test.ts | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 src/__tests__/NodesTranslator.preventRecursion.test.ts diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts new file mode 100644 index 0000000..b3d17d6 --- /dev/null +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -0,0 +1,134 @@ +import { DOMNodesTranslator } from '../DOMNodesTranslator'; +import { TranslationDispatcher } from '../TranslationDispatcher'; +import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; +import { NodesTranslator } from '..'; + +test('Translating a node does not trigger recursive updateNode calls.', async () => { + const nodeTranslator = new DOMNodesTranslator(translator); + const dispatcher = new TranslationDispatcher({ + filter: () => true, + nodeTranslator: nodeTranslator, + }); + const nodesTranslator = new NodesTranslator({ dispatcher, nodeTranslator }); + const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); + + const div = document.createElement('div'); + const text = 'simple short text'; + div.textContent = text; + document.body.appendChild(div); + nodesTranslator.observe(div); + + await awaitTranslation(); + await awaitTranslation(); + expect(updateNodeSpy.mock.calls).toEqual([]); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); +}); + +test('Translation of added nodes does not trigger recursive updateNode calls.', async () => { + const nodeTranslator = new DOMNodesTranslator(translator); + const dispatcher = new TranslationDispatcher({ + filter: () => true, + nodeTranslator: nodeTranslator, + }); + const nodesTranslator = new NodesTranslator({ dispatcher, nodeTranslator }); + const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); + + const div = document.createElement('div'); + const text = 'Siple short text'; + div.textContent = text; + document.body.appendChild(div); + nodesTranslator.observe(div); + + await awaitTranslation(); + expect(updateNodeSpy.mock.calls).toEqual([]); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // add new element + const div1 = document.createElement('div'); + const text1 = 'Not a rectangle, but a square'; + div1.textContent = text1; + div.appendChild(div1); + + await awaitTranslation(); + expect(updateNodeSpy.mock.calls).toEqual([]); + expect(div1.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // add new attribute + const text2 = 'Short text'; + div.setAttribute('title', text2); + + await awaitTranslation(); + expect(updateNodeSpy.mock.calls).toEqual([]); + expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); +}); + +test('Updating an attribute multiple times does not trigger recursive updateNode calls', async () => { + const nodeTranslator = new DOMNodesTranslator(translator); + const dispatcher = new TranslationDispatcher({ + filter: () => true, + nodeTranslator: nodeTranslator, + }); + const nodesTranslator = new NodesTranslator({ dispatcher, nodeTranslator }); + const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); + + const div = document.createElement('a'); + const text = 'title text'; + div.setAttribute('title', text); + document.body.appendChild(div); + nodesTranslator.observe(div); + + await awaitTranslation(); + expect(updateNodeSpy.mock.calls).toEqual([]); + expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // update content, node should be translated + const text1 = 'new Text'; + div.setAttribute('title', text1); + await awaitTranslation(); + + expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.attributes[0]); + expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.getAttribute('title')).toMatch(text1); + + // update content again, node should be translated + const text2 = 'new Text with new information'; + div.setAttribute('title', text2); + await awaitTranslation(); + + expect(updateNodeSpy.mock.calls[1][0]).toEqual(div.attributes[0]); + expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.getAttribute('title')).toMatch(text2); +}); + +test('Updating a node with a translated-looking value triggers updateNode calls', async () => { + const nodeTranslator = new DOMNodesTranslator(translator); + const dispatcher = new TranslationDispatcher({ + filter: () => true, + nodeTranslator: nodeTranslator, + }); + const nodesTranslator = new NodesTranslator({ dispatcher, nodeTranslator }); + const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); + + const div = document.createElement('a'); + const text = 'title text'; + div.setAttribute('title', text); + document.body.appendChild(div); + nodesTranslator.observe(div); + + await awaitTranslation(); + // updateNode should not be called + expect(updateNodeSpy.mock.calls).toEqual([]); + expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + + const text1 = TRANSLATION_SYMBOL + 'title text'; + div.setAttribute('title', text1); + + await awaitTranslation(); + expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.attributes[0]); + expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.getAttribute('title')).toMatch(text1); + + // restored node has the last set text + nodesTranslator.unobserve(div); + expect(div.getAttribute('title')).toBe(text1); +}); From 5592553bc7d6dc658343490bbe8099bc26dcd94c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 23 May 2025 01:09:03 +0200 Subject: [PATCH 196/313] chore: improve style, fix typo --- src/NodesIntersectionObserver.ts | 37 +++++++++++++++----------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/NodesIntersectionObserver.ts b/src/NodesIntersectionObserver.ts index 137f78c..c917af6 100644 --- a/src/NodesIntersectionObserver.ts +++ b/src/NodesIntersectionObserver.ts @@ -11,14 +11,14 @@ type Callback = (node: Node) => void; /** * Observes DOM nodes for intersection with the viewport and triggers callbacks when they become visible. - * WARNING: This class works with nodes (Text, Attr, etc.), not directly with elements. + * WARNING: This class works with nodes (Text, Attr, etc.), not directly with Element nodes. */ export class NodesIntersectionObserver { private readonly intersectionObserver: IntersectionObserver; - private readonly nodeCallbacksMap = new WeakMap(); // Stores nodes and their owner element that are being observed for intersection private readonly elementNodesMap = new WeakMap>(); + private readonly nodeCallbacksMap = new WeakMap(); constructor(intersectionConfig?: IntersectionObserverInit) { this.intersectionObserver = new IntersectionObserver((entries, observer) => { @@ -31,7 +31,6 @@ export class NodesIntersectionObserver { this.triggerNestedNodes(node); // Process the element once and stop observing it - // This allows re-observing the element later if needed this.elementNodesMap.delete(node); observer.unobserve(node); }); @@ -40,7 +39,7 @@ export class NodesIntersectionObserver { /** * Starts observing the node for intersection. - * When the element that owns the node intersects the viewport, the callback is invoked. + * When the owner element of the node intersects the viewport, the callback is invoked. * Then the owner element and all its tracked nodes are automatically removed from observation. */ public observe(node: Node, callback: Callback) { @@ -52,23 +51,19 @@ export class NodesIntersectionObserver { return; } - // set the callback for the node this.nodeCallbacksMap.set(node, callback); - // add ownerElement if it doesn't exist in the map const observedNodes = this.elementNodesMap.get(ownerElement); - if (!observedNodes) { - this.elementNodesMap.set(ownerElement, new Set().add(node)); + if (observedNodes) { + observedNodes.add(node); + } else { + this.elementNodesMap.set(ownerElement, new Set([node])); this.intersectionObserver.observe(ownerElement); - return; } - - // add the node to the set of observed nodes - observedNodes.add(node); } /** - * Stops observing the node. It is removes from observation. + * Stops observing the node and removes it from observation */ public unobserve(node: Node) { const ownerElement = getElementOfNode(node); @@ -76,7 +71,7 @@ export class NodesIntersectionObserver { const observedNodes = this.elementNodesMap.get(ownerElement); if (!observedNodes || !observedNodes.has(node)) return; - // delete only the specified node + // remove only the specified node observedNodes.delete(node); this.nodeCallbacksMap.delete(node); @@ -88,15 +83,17 @@ export class NodesIntersectionObserver { } /** - * Calls callbacks for all observed nodes associated with the specified element and removes their callbacks from storage + * Calls callbacks for all nodes associated with the specified element and removes their callbacks from storage */ private triggerNestedNodes(node: Element) { const ownedNodes = this.elementNodesMap.get(node); - ownedNodes?.forEach((node) => { - const callback = this.nodeCallbacksMap.get(node); - if (callback) callback(node); + if (ownedNodes) { + ownedNodes.forEach((node) => { + const callback = this.nodeCallbacksMap.get(node); + if (callback) callback(node); - this.nodeCallbacksMap.delete(node); - }); + this.nodeCallbacksMap.delete(node); + }); + } } } From 8a821bf900b566a11eb034ba603863e0f4d993c0 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 23 May 2025 13:30:17 +0200 Subject: [PATCH 197/313] fix: incorrect prevent recursion --- src/DOMNodesTranslator.ts | 16 ++-- src/NodesTranslator.ts | 85 +++++++-------------- src/TranslationDispatcher.ts | 13 ++-- src/__tests__/DOMNodesTranslator.test.ts | 14 ++-- src/__tests__/TranslationDispatcher.test.ts | 8 +- 5 files changed, 54 insertions(+), 82 deletions(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 6561eaa..30845b6 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -1,4 +1,5 @@ import { isInViewport } from './utils/isInViewport'; +import { NodeTranslationHandler } from '.'; export type TranslatorInterface = (text: string, priority: number) => Promise; @@ -70,9 +71,10 @@ export class DOMNodesTranslator { } /** - * Translate text-containing nodes (Text, Attr, etc) + * Translates nodes that contain text (e.g., Text, Attr) + * After translation invokes a callback with the translated node */ - public translateNode = (node: Node) => { + public translateNode = (node: Node, callback: NodeTranslationHandler) => { if (this.hasNode(node)) return; // Skip empty text @@ -85,7 +87,7 @@ export class DOMNodesTranslator { priority: getNodePriority(node), }); - this.translateNodeContent(node); + this.translateNodeContent(node, callback); }; /** @@ -104,19 +106,20 @@ export class DOMNodesTranslator { /** * Translates node after it has been modified + * After translation invokes a callback with the translated node */ - public updateNode(node: Node) { + public updateNode(node: Node, callback: NodeTranslationHandler) { const nodeData = this.nodeStorage.get(node); if (!nodeData) return; nodeData.updateId++; - this.translateNodeContent(node); + this.translateNodeContent(node, callback); } /** * Call only for new and updated nodes */ - private translateNodeContent(node: Node) { + private translateNodeContent(node: Node, callback: NodeTranslationHandler) { const nodeData = this.nodeStorage.get(node); if (!nodeData) { throw new Error('Node is not register'); @@ -137,6 +140,7 @@ export class DOMNodesTranslator { actualNodeData.originalText = node.nodeValue !== null ? node.nodeValue : ''; node.nodeValue = text; + callback(node); }); } } diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 2a8475b..9fdefdd 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,12 +1,13 @@ import { XMutationObserver } from './lib/XMutationObserver'; import { TranslationDispatcher } from './TranslationDispatcher'; -import { visitWholeTree } from './utils/visitWholeTree'; import { DOMNodesTranslator } from '.'; // TODO: consider local language definitions (and implement `from`, `to` parameters for translator to specify default or locale languages) // TODO: scan nodes lazy - defer scan to `requestIdleCallback` instead of instant scan // TODO: describe nodes life cycle +export type NodeTranslationHandler = (node: Node) => void; + /** * Module for dynamic translate a DOM nodes. */ @@ -25,11 +26,18 @@ export class NodesTranslator { this.nodeTranslator = nodeTranslator; } - /** - * Stores the last node value to prevent redundant reprocessing. - * node value `null` marks intentional changes that shouldn't trigger processing. - */ - private lastNodesValue = new WeakMap(); + private nodeTranslationMap = new WeakMap(); + private processNodeChanges = (node: Node, callback: (node: Node) => void) => { + // skip this node + if (this.nodeTranslationMap.get(node) === node.nodeValue) { + this.nodeTranslationMap.delete(node); + return; + } + callback(node); + }; + private translationHandler = (node: Node) => { + if (node.nodeValue) this.nodeTranslationMap.set(node, node.nodeValue); + }; private readonly observedNodesStorage = new Map(); public observe(node: Element) { @@ -42,23 +50,18 @@ export class NodesTranslator { this.observedNodesStorage.set(node, observer); observer.addHandler('elementAdded', ({ target }) => { - this.processNodeChanges(target, () => { - this.dispatcher.translateNode(target); - }); + this.dispatcher.translateNode(target, this.translationHandler); }); observer.addHandler('elementRemoved', ({ target }) => { - this.deleteLastNodeValue(target); - this.processNodeChanges(target, () => { - this.dispatcher.restoreNode(target); - }); + this.nodeTranslationMap.delete(target); + this.dispatcher.restoreNode(target); }); observer.addHandler('characterData', ({ target }) => { - this.setLastNodeValue(target, target.nodeValue); this.processNodeChanges(target, () => { - this.dispatcher.updateNode(target); + this.dispatcher.updateNode(target, this.translationHandler); }); }); - observer.addHandler('changeAttribute', ({ target, attributeName }) => { + observer.addHandler('changeAttribute', ({ target, attributeName, oldValue }) => { if (attributeName === undefined || attributeName === null) return; if (!(target instanceof Element)) return; @@ -66,27 +69,22 @@ export class NodesTranslator { if (attribute === null) return; - // If the value is null, it means the node has just been processed, - // we should only handle the next changes - if (this.getLastNodeValue(attribute) !== null) { - this.setLastNodeValue(attribute, attribute.value); - } - // NOTE: If need delete untracked nodes, we should keep relates like Element -> attributes if (!this.dispatcher.hasNode(attribute)) { - this.processNodeChanges(attribute, () => { - this.dispatcher.translateNode(attribute); - }); + // if node was replaces delete form storage + if (oldValue && oldValue === attribute.value) { + this.nodeTranslationMap.delete(attribute); + } + this.dispatcher.translateNode(attribute, this.translationHandler); } else { this.processNodeChanges(attribute, () => { - this.dispatcher.updateNode(attribute); + this.dispatcher.updateNode(attribute, this.translationHandler); }); } }); observer.observe(node); - - this.dispatcher.translateNode(node); + this.dispatcher.translateNode(node, this.translationHandler); } public unobserve(node: Element) { @@ -97,40 +95,9 @@ export class NodesTranslator { this.dispatcher.restoreNode(node); this.observedNodesStorage.get(node)?.disconnect(); this.observedNodesStorage.delete(node); - this.clearLastNodesValueStorage(node); } public getNodeData(node: Node) { return this.nodeTranslator.getOriginalNodeText(node); } - - // Runs callback only if node value has changed to prevent recursion. - private processNodeChanges = (node: Node, callback: (node: Node) => void) => { - const actualValue = node.nodeValue; - const expectedValue = this.getLastNodeValue(node); - - // If the value has not changed, skip the callback - if (expectedValue !== undefined && actualValue === expectedValue) { - this.setLastNodeValue(node, null); - return; - } - callback(node); - - // save the new value for future change detection - this.setLastNodeValue(node, node.nodeValue); - }; - - private setLastNodeValue = (node: Node, value: string | null) => - this.lastNodesValue.set(node, value); - private getLastNodeValue = (node: Node) => this.lastNodesValue.get(node); - private deleteLastNodeValue = (node: Node) => this.lastNodesValue.delete(node); - - // removes all saved values for the given element - private clearLastNodesValueStorage(node: Element) { - visitWholeTree(node, () => { - if (node instanceof Element) return; - this.clearLastNodesValueStorage(node); - }); - this.deleteLastNodeValue(node); - } } diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index d9d8d8a..59d2736 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -1,6 +1,7 @@ import { DOMNodesTranslator } from './DOMNodesTranslator'; import { NodesIntersectionObserver } from './NodesIntersectionObserver'; import { visitWholeTree } from './utils/visitWholeTree'; +import { NodeTranslationHandler } from '.'; export type TranslatableNodePredicate = (node: Node) => boolean; @@ -30,8 +31,8 @@ export class TranslationDispatcher { this.nodeIntersectionObserver = nodeIntersectionObserver || null; } - public updateNode(node: Node) { - this.nodeTranslator.updateNode(node); + public updateNode(node: Node, callback: NodeTranslationHandler) { + this.nodeTranslator.updateNode(node, callback); } public hasNode(node: Node) { @@ -40,7 +41,7 @@ export class TranslationDispatcher { /** * Translates the node and all its nested translatable nodes (text and attribute nodes) */ - public translateNode(node: Node) { + public translateNode(node: Node, callback: NodeTranslationHandler) { // Skip node if it does not satisfy the filter if (!this.filter(node)) return; @@ -48,7 +49,7 @@ export class TranslationDispatcher { if (node instanceof Element) { visitWholeTree(node, (node) => { if (node instanceof Element) return; - this.translateNode(node); + this.translateNode(node, callback); }); return; } @@ -61,13 +62,13 @@ export class TranslationDispatcher { const isAttachedToDOM = node.getRootNode() !== node; if (isAttachedToDOM) { this.nodeIntersectionObserver.observe(node, (node: Node) => { - this.nodeTranslator.translateNode(node); + this.nodeTranslator.translateNode(node, callback); }); return; } } // translate immediately - this.nodeTranslator.translateNode(node); + this.nodeTranslator.translateNode(node, callback); } /** diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index e6a08a2..7b12444 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -8,7 +8,7 @@ test('Translates a node and restores the original node text', async () => { const div = document.createElement('div'); div.textContent = nodeText; - domNodesTranslator.translateNode(div.childNodes[0]); + domNodesTranslator.translateNode(div.childNodes[0], () => {}); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); @@ -26,7 +26,7 @@ test('Stores original text on translation and clears it on restoration', async ( // before translation expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); - domNodesTranslator.translateNode(div.childNodes[0]); + domNodesTranslator.translateNode(div.childNodes[0], () => {}); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); @@ -48,7 +48,7 @@ test('Stores node during translation and removes it upon restoration', async () // not exists before translate expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(false); - domNodesTranslator.translateNode(div.childNodes[0]); + domNodesTranslator.translateNode(div.childNodes[0], () => {}); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(true); @@ -66,14 +66,14 @@ test('Updates translation when attribute value changes', async () => { node.setAttribute('title', text); // translate - domNodesTranslator.translateNode(node.attributes[0]); + domNodesTranslator.translateNode(node.attributes[0], () => {}); await awaitTranslation(); expect(node.attributes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); // update value const text1 = 'title text is update'; node.setAttribute('title', text1); - domNodesTranslator.updateNode(node.attributes[0]); + domNodesTranslator.updateNode(node.attributes[0], () => {}); await awaitTranslation(); expect(node.attributes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(node.attributes[0].textContent).toMatch(text); @@ -86,14 +86,14 @@ test('Restores the most recent original text after multiple translations', async const nodeText = 'Hello world!'; div.textContent = nodeText; - domNodesTranslator.translateNode(div.childNodes[0]); + domNodesTranslator.translateNode(div.childNodes[0], () => {}); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); // change text const nodeText1 = 'My name is Jake'; div.textContent = nodeText1; - domNodesTranslator.translateNode(div.childNodes[0]); + domNodesTranslator.translateNode(div.childNodes[0], () => {}); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 7c784d2..7b8d101 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -39,7 +39,7 @@ test('In lazy-translation mode a non-intersecting node translates immediately', mockBoundingClientRect(document.body, { width: 100, height: 200, x: 0, y: 0 }); // the element is translated regardless of viewport intersection - translationDispatcher.translateNode(select); + translationDispatcher.translateNode(select, () => {}); await awaitTranslation(); expect(option.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); @@ -60,7 +60,7 @@ test('In lazy-translation mode a node not attached to the body translates immedi title.textContent = text; head.appendChild(title); - translationDispatcher.translateNode(head); + translationDispatcher.translateNode(head, () => {}); await awaitTranslation(); expect(title.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); @@ -80,7 +80,7 @@ test('Translates and restores the element and its child elements', async () => { nodeTranslator: new DOMNodesTranslator(translator), }); - translationDispatcher.translateNode(div); + translationDispatcher.translateNode(div, () => {}); await awaitTranslation(); // check the text on the element itself expect(div.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); @@ -109,7 +109,7 @@ test('Does not translate ignored node', async () => { div.appendChild(comment); document.body.appendChild(div); - translationDispatcher.translateNode(div); + translationDispatcher.translateNode(div, () => {}); await awaitTranslation(); expect(div.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); From bc18c9fd5eae6a1e72e36652a5bf7fb056985ad1 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 23 May 2025 19:37:10 +0200 Subject: [PATCH 198/313] test: incorrect test flow --- .../NodesTranslator.preventRecursion.test.ts | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index b3d17d6..9729429 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -19,9 +19,9 @@ test('Translating a node does not trigger recursive updateNode calls.', async () nodesTranslator.observe(div); await awaitTranslation(); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); await awaitTranslation(); expect(updateNodeSpy.mock.calls).toEqual([]); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); test('Translation of added nodes does not trigger recursive updateNode calls.', async () => { @@ -40,8 +40,9 @@ test('Translation of added nodes does not trigger recursive updateNode calls.', nodesTranslator.observe(div); await awaitTranslation(); - expect(updateNodeSpy.mock.calls).toEqual([]); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + await awaitTranslation(); + expect(updateNodeSpy.mock.calls).toEqual([]); // add new element const div1 = document.createElement('div'); @@ -50,16 +51,18 @@ test('Translation of added nodes does not trigger recursive updateNode calls.', div.appendChild(div1); await awaitTranslation(); - expect(updateNodeSpy.mock.calls).toEqual([]); expect(div1.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + await awaitTranslation(); + expect(updateNodeSpy.mock.calls).toEqual([]); // add new attribute const text2 = 'Short text'; div.setAttribute('title', text2); await awaitTranslation(); - expect(updateNodeSpy.mock.calls).toEqual([]); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + await awaitTranslation(); + expect(updateNodeSpy.mock.calls).toEqual([]); }); test('Updating an attribute multiple times does not trigger recursive updateNode calls', async () => { @@ -78,26 +81,29 @@ test('Updating an attribute multiple times does not trigger recursive updateNode nodesTranslator.observe(div); await awaitTranslation(); - expect(updateNodeSpy.mock.calls).toEqual([]); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + await awaitTranslation(); + expect(updateNodeSpy.mock.calls).toEqual([]); // update content, node should be translated const text1 = 'new Text'; div.setAttribute('title', text1); await awaitTranslation(); - - expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.attributes[0]); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(div.getAttribute('title')).toMatch(text1); + await awaitTranslation(); + expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.attributes[0]); + // update content again, node should be translated const text2 = 'new Text with new information'; div.setAttribute('title', text2); await awaitTranslation(); - - expect(updateNodeSpy.mock.calls[1][0]).toEqual(div.attributes[0]); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(div.getAttribute('title')).toMatch(text2); + + await awaitTranslation(); + expect(updateNodeSpy.mock.calls[1][0]).toEqual(div.attributes[0]); }); test('Updating a node with a translated-looking value triggers updateNode calls', async () => { @@ -113,21 +119,24 @@ test('Updating a node with a translated-looking value triggers updateNode calls' const text = 'title text'; div.setAttribute('title', text); document.body.appendChild(div); - nodesTranslator.observe(div); + nodesTranslator.observe(div); await awaitTranslation(); + expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + // updateNode should not be called + await awaitTranslation(); expect(updateNodeSpy.mock.calls).toEqual([]); - expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); const text1 = TRANSLATION_SYMBOL + 'title text'; div.setAttribute('title', text1); - await awaitTranslation(); - expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.attributes[0]); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(div.getAttribute('title')).toMatch(text1); + await awaitTranslation(); + expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.attributes[0]); + // restored node has the last set text nodesTranslator.unobserve(div); expect(div.getAttribute('title')).toBe(text1); From bfba8c36360fcede06b135980718c36e34beb72f Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 23 May 2025 20:17:19 +0200 Subject: [PATCH 199/313] refactor: simplify --- src/NodesTranslator.ts | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 9fdefdd..7314655 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -26,17 +26,13 @@ export class NodesTranslator { this.nodeTranslator = nodeTranslator; } - private nodeTranslationMap = new WeakMap(); - private processNodeChanges = (node: Node, callback: (node: Node) => void) => { - // skip this node - if (this.nodeTranslationMap.get(node) === node.nodeValue) { - this.nodeTranslationMap.delete(node); - return; - } - callback(node); + private translatedNodes = new WeakMap(); + private shouldSkipNode = (node: Node) => { + return this.translatedNodes.get(node) === node.nodeValue; }; + /** Save node translation to storage */ private translationHandler = (node: Node) => { - if (node.nodeValue) this.nodeTranslationMap.set(node, node.nodeValue); + if (node.nodeValue) this.translatedNodes.set(node, node.nodeValue); }; private readonly observedNodesStorage = new Map(); @@ -53,13 +49,15 @@ export class NodesTranslator { this.dispatcher.translateNode(target, this.translationHandler); }); observer.addHandler('elementRemoved', ({ target }) => { - this.nodeTranslationMap.delete(target); + this.translatedNodes.delete(target); this.dispatcher.restoreNode(target); }); observer.addHandler('characterData', ({ target }) => { - this.processNodeChanges(target, () => { - this.dispatcher.updateNode(target, this.translationHandler); - }); + if (this.shouldSkipNode(target)) { + this.translatedNodes.delete(target); + return; + } + this.dispatcher.updateNode(target, this.translationHandler); }); observer.addHandler('changeAttribute', ({ target, attributeName, oldValue }) => { if (attributeName === undefined || attributeName === null) return; @@ -72,14 +70,16 @@ export class NodesTranslator { // NOTE: If need delete untracked nodes, we should keep relates like Element -> attributes if (!this.dispatcher.hasNode(attribute)) { // if node was replaces delete form storage - if (oldValue && oldValue === attribute.value) { - this.nodeTranslationMap.delete(attribute); + if (oldValue === attribute.value) { + this.translatedNodes.delete(attribute); } this.dispatcher.translateNode(attribute, this.translationHandler); } else { - this.processNodeChanges(attribute, () => { - this.dispatcher.updateNode(attribute, this.translationHandler); - }); + if (this.shouldSkipNode(attribute)) { + this.translatedNodes.delete(attribute); + return; + } + this.dispatcher.updateNode(attribute, this.translationHandler); } }); From 2b6b1032f582fffe67e91c1283872efa6c4e19ab Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 23 May 2025 20:34:51 +0200 Subject: [PATCH 200/313] refactor: clean storage --- src/NodesTranslator.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 7314655..4362575 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,5 +1,6 @@ import { XMutationObserver } from './lib/XMutationObserver'; import { TranslationDispatcher } from './TranslationDispatcher'; +import { visitWholeTree } from './utils/visitWholeTree'; import { DOMNodesTranslator } from '.'; // TODO: consider local language definitions (and implement `from`, `to` parameters for translator to specify default or locale languages) @@ -27,9 +28,8 @@ export class NodesTranslator { } private translatedNodes = new WeakMap(); - private shouldSkipNode = (node: Node) => { - return this.translatedNodes.get(node) === node.nodeValue; - }; + private shouldSkipNode = (node: Node) => + this.translatedNodes.get(node) === node.nodeValue; /** Save node translation to storage */ private translationHandler = (node: Node) => { if (node.nodeValue) this.translatedNodes.set(node, node.nodeValue); @@ -95,6 +95,9 @@ export class NodesTranslator { this.dispatcher.restoreNode(node); this.observedNodesStorage.get(node)?.disconnect(); this.observedNodesStorage.delete(node); + visitWholeTree(node, () => { + this.translatedNodes.delete(node); + }); } public getNodeData(node: Node) { From ae92b29bf09de5a249456476f6a8a6019de165bc Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 23 May 2025 20:38:19 +0200 Subject: [PATCH 201/313] chore: typo --- src/__tests__/NodesTranslator.preventRecursion.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index 9729429..9792f8d 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -3,7 +3,7 @@ import { TranslationDispatcher } from '../TranslationDispatcher'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; import { NodesTranslator } from '..'; -test('Translating a node does not trigger recursive updateNode calls.', async () => { +test('Translating a node does not trigger recursive updateNode calls', async () => { const nodeTranslator = new DOMNodesTranslator(translator); const dispatcher = new TranslationDispatcher({ filter: () => true, @@ -24,7 +24,7 @@ test('Translating a node does not trigger recursive updateNode calls.', async () expect(updateNodeSpy.mock.calls).toEqual([]); }); -test('Translation of added nodes does not trigger recursive updateNode calls.', async () => { +test('Translation of added nodes does not trigger recursive updateNode calls', async () => { const nodeTranslator = new DOMNodesTranslator(translator); const dispatcher = new TranslationDispatcher({ filter: () => true, From 45740c65722b933e17152f95e133b542cf551336 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 23 May 2025 21:02:08 +0200 Subject: [PATCH 202/313] revert: 3c5522a2a6c5e4aaa76f1014cf33e8563588839c --- src/NodesTranslator.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 4362575..76d60ed 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,6 +1,5 @@ import { XMutationObserver } from './lib/XMutationObserver'; import { TranslationDispatcher } from './TranslationDispatcher'; -import { visitWholeTree } from './utils/visitWholeTree'; import { DOMNodesTranslator } from '.'; // TODO: consider local language definitions (and implement `from`, `to` parameters for translator to specify default or locale languages) @@ -95,9 +94,6 @@ export class NodesTranslator { this.dispatcher.restoreNode(node); this.observedNodesStorage.get(node)?.disconnect(); this.observedNodesStorage.delete(node); - visitWholeTree(node, () => { - this.translatedNodes.delete(node); - }); } public getNodeData(node: Node) { From b53fe0b02981100f4cce6294388b5fbeb4879550 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 23 May 2025 21:08:22 +0200 Subject: [PATCH 203/313] chore: rename --- src/NodesTranslator.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 76d60ed..c1b3e1d 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -9,7 +9,7 @@ import { DOMNodesTranslator } from '.'; export type NodeTranslationHandler = (node: Node) => void; /** - * Module for dynamic translate a DOM nodes. + * Module for dynamic translate a DOM nodes */ export class NodesTranslator { private readonly dispatcher; @@ -29,8 +29,7 @@ export class NodesTranslator { private translatedNodes = new WeakMap(); private shouldSkipNode = (node: Node) => this.translatedNodes.get(node) === node.nodeValue; - /** Save node translation to storage */ - private translationHandler = (node: Node) => { + private saveTranslatedNode = (node: Node) => { if (node.nodeValue) this.translatedNodes.set(node, node.nodeValue); }; @@ -45,7 +44,7 @@ export class NodesTranslator { this.observedNodesStorage.set(node, observer); observer.addHandler('elementAdded', ({ target }) => { - this.dispatcher.translateNode(target, this.translationHandler); + this.dispatcher.translateNode(target, this.saveTranslatedNode); }); observer.addHandler('elementRemoved', ({ target }) => { this.translatedNodes.delete(target); @@ -56,7 +55,7 @@ export class NodesTranslator { this.translatedNodes.delete(target); return; } - this.dispatcher.updateNode(target, this.translationHandler); + this.dispatcher.updateNode(target, this.saveTranslatedNode); }); observer.addHandler('changeAttribute', ({ target, attributeName, oldValue }) => { if (attributeName === undefined || attributeName === null) return; @@ -72,18 +71,18 @@ export class NodesTranslator { if (oldValue === attribute.value) { this.translatedNodes.delete(attribute); } - this.dispatcher.translateNode(attribute, this.translationHandler); + this.dispatcher.translateNode(attribute, this.saveTranslatedNode); } else { if (this.shouldSkipNode(attribute)) { this.translatedNodes.delete(attribute); return; } - this.dispatcher.updateNode(attribute, this.translationHandler); + this.dispatcher.updateNode(attribute, this.saveTranslatedNode); } }); observer.observe(node); - this.dispatcher.translateNode(node, this.translationHandler); + this.dispatcher.translateNode(node, this.saveTranslatedNode); } public unobserve(node: Element) { From 33923fcd47c083cc7231fde7d1f74ca99b52a775 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 23 May 2025 21:11:56 +0200 Subject: [PATCH 204/313] refactor: remove unnecessary clean --- src/NodesTranslator.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index c1b3e1d..f474027 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -47,7 +47,6 @@ export class NodesTranslator { this.dispatcher.translateNode(target, this.saveTranslatedNode); }); observer.addHandler('elementRemoved', ({ target }) => { - this.translatedNodes.delete(target); this.dispatcher.restoreNode(target); }); observer.addHandler('characterData', ({ target }) => { From 95238852003a15d6b148a117241cbceb1905e08c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 23 May 2025 22:27:00 +0200 Subject: [PATCH 205/313] refactor: simplify storage --- src/NodesTranslator.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index f474027..d5911ec 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -26,12 +26,9 @@ export class NodesTranslator { this.nodeTranslator = nodeTranslator; } - private translatedNodes = new WeakMap(); - private shouldSkipNode = (node: Node) => - this.translatedNodes.get(node) === node.nodeValue; - private saveTranslatedNode = (node: Node) => { - if (node.nodeValue) this.translatedNodes.set(node, node.nodeValue); - }; + private translatedNodes = new WeakSet(); + private shouldSkipNode = (node: Node) => this.translatedNodes.has(node); + private saveTranslatedNode = (node: Node) => this.translatedNodes.add(node); private readonly observedNodesStorage = new Map(); public observe(node: Element) { @@ -50,6 +47,7 @@ export class NodesTranslator { this.dispatcher.restoreNode(target); }); observer.addHandler('characterData', ({ target }) => { + // skip this update because it was triggered by the translation itself if (this.shouldSkipNode(target)) { this.translatedNodes.delete(target); return; @@ -72,6 +70,7 @@ export class NodesTranslator { } this.dispatcher.translateNode(attribute, this.saveTranslatedNode); } else { + // skip this update because it was triggered by the translation itself if (this.shouldSkipNode(attribute)) { this.translatedNodes.delete(attribute); return; From 8d4e42225c4d4944d63cfe2c493181a33080fa90 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 23 May 2025 23:16:30 +0200 Subject: [PATCH 206/313] test: add test case --- .../NodesTranslator.preventRecursion.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index 9792f8d..df72288 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -141,3 +141,55 @@ test('Updating a node with a translated-looking value triggers updateNode calls' nodesTranslator.unobserve(div); expect(div.getAttribute('title')).toBe(text1); }); + +test('Only the latest translation will be applied to the node', async () => { + const translator = vi + .fn() + .mockImplementationOnce( + (text: string) => + new Promise((resolve) => + setTimeout(() => resolve((text += TRANSLATION_SYMBOL)), 300), + ), + ) + .mockImplementationOnce( + (text: string) => + new Promise((resolve) => + setTimeout(() => resolve((text += TRANSLATION_SYMBOL)), 100), + ), + ); + const nodeTranslator = new DOMNodesTranslator(translator); + const dispatcher = new TranslationDispatcher({ + filter: () => true, + nodeTranslator: nodeTranslator, + }); + const nodesTranslator = new NodesTranslator({ dispatcher, nodeTranslator }); + const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + const div = document.createElement('a'); + const text = 'title text'; + div.setAttribute('title', text); + document.body.appendChild(div); + nodesTranslator.observe(div); + + // this translation completes within 300 ms, do not wait for completion + await delay(100); + expect(translator).toBeCalledTimes(1); + expect(div.getAttribute('title')).toBe(text); + + const text1 = 'you must translate me'; + div.setAttribute('title', text1); + + // this translation completes in 100 ms, wait for it + await delay(110); + expect(translator).toBeCalledTimes(2); + expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.getAttribute('title')).toMatch(text1); + + // wait for the first translation to finish, it does not modify the node + await delay(200); + expect(div.getAttribute('title')).toMatch(text1); + + // reset + nodesTranslator.unobserve(div); + expect(div.getAttribute('title')).toBe(text1); +}); From be104aaa835046b7ea72e96f7d5024804de1ac84 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 23 May 2025 23:55:08 +0200 Subject: [PATCH 207/313] test: improve style, fix typo --- .../NodesTranslator.preventRecursion.test.ts | 65 ++++++++----------- 1 file changed, 26 insertions(+), 39 deletions(-) diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index df72288..f2dbad1 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -1,14 +1,19 @@ import { DOMNodesTranslator } from '../DOMNodesTranslator'; import { TranslationDispatcher } from '../TranslationDispatcher'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; -import { NodesTranslator } from '..'; +import { NodesTranslator, TranslatorInterface } from '..'; -test('Translating a node does not trigger recursive updateNode calls', async () => { +function buildTranslationServices(translator: TranslatorInterface) { const nodeTranslator = new DOMNodesTranslator(translator); const dispatcher = new TranslationDispatcher({ filter: () => true, nodeTranslator: nodeTranslator, }); + return { dispatcher, nodeTranslator }; +} + +test('Translating a node does not trigger recursive updateNode calls', async () => { + const { dispatcher, nodeTranslator } = buildTranslationServices(translator); const nodesTranslator = new NodesTranslator({ dispatcher, nodeTranslator }); const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); @@ -25,11 +30,7 @@ test('Translating a node does not trigger recursive updateNode calls', async () }); test('Translation of added nodes does not trigger recursive updateNode calls', async () => { - const nodeTranslator = new DOMNodesTranslator(translator); - const dispatcher = new TranslationDispatcher({ - filter: () => true, - nodeTranslator: nodeTranslator, - }); + const { dispatcher, nodeTranslator } = buildTranslationServices(translator); const nodesTranslator = new NodesTranslator({ dispatcher, nodeTranslator }); const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); @@ -65,12 +66,8 @@ test('Translation of added nodes does not trigger recursive updateNode calls', a expect(updateNodeSpy.mock.calls).toEqual([]); }); -test('Updating an attribute multiple times does not trigger recursive updateNode calls', async () => { - const nodeTranslator = new DOMNodesTranslator(translator); - const dispatcher = new TranslationDispatcher({ - filter: () => true, - nodeTranslator: nodeTranslator, - }); +test('Updating node does not trigger recursive updateNode calls', async () => { + const { dispatcher, nodeTranslator } = buildTranslationServices(translator); const nodesTranslator = new NodesTranslator({ dispatcher, nodeTranslator }); const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); @@ -85,33 +82,24 @@ test('Updating an attribute multiple times does not trigger recursive updateNode await awaitTranslation(); expect(updateNodeSpy.mock.calls).toEqual([]); - // update content, node should be translated + // update content, node should be translated without triggering recursion const text1 = 'new Text'; div.setAttribute('title', text1); await awaitTranslation(); + expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.attributes[0]); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(div.getAttribute('title')).toMatch(text1); + // no recursion await awaitTranslation(); - expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.attributes[0]); + expect(updateNodeSpy).toBeCalledTimes(1); - // update content again, node should be translated - const text2 = 'new Text with new information'; - div.setAttribute('title', text2); - await awaitTranslation(); - expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(div.getAttribute('title')).toMatch(text2); - - await awaitTranslation(); - expect(updateNodeSpy.mock.calls[1][0]).toEqual(div.attributes[0]); + nodesTranslator.unobserve(div); + expect(div.getAttribute('title')).toBe(text1); }); test('Updating a node with a translated-looking value triggers updateNode calls', async () => { - const nodeTranslator = new DOMNodesTranslator(translator); - const dispatcher = new TranslationDispatcher({ - filter: () => true, - nodeTranslator: nodeTranslator, - }); + const { dispatcher, nodeTranslator } = buildTranslationServices(translator); const nodesTranslator = new NodesTranslator({ dispatcher, nodeTranslator }); const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); @@ -128,16 +116,19 @@ test('Updating a node with a translated-looking value triggers updateNode calls' await awaitTranslation(); expect(updateNodeSpy.mock.calls).toEqual([]); + // update content, node should be translated without triggering recursion const text1 = TRANSLATION_SYMBOL + 'title text'; div.setAttribute('title', text1); await awaitTranslation(); + expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.attributes[0]); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(div.getAttribute('title')).toMatch(text1); + expect(div.getAttribute('title')).toMatch(TRANSLATION_SYMBOL + text1); + // no recursion await awaitTranslation(); - expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.attributes[0]); + expect(updateNodeSpy).toHaveBeenCalledTimes(1); - // restored node has the last set text + // restored node has the latest text nodesTranslator.unobserve(div); expect(div.getAttribute('title')).toBe(text1); }); @@ -157,11 +148,7 @@ test('Only the latest translation will be applied to the node', async () => { setTimeout(() => resolve((text += TRANSLATION_SYMBOL)), 100), ), ); - const nodeTranslator = new DOMNodesTranslator(translator); - const dispatcher = new TranslationDispatcher({ - filter: () => true, - nodeTranslator: nodeTranslator, - }); + const { dispatcher, nodeTranslator } = buildTranslationServices(translator); const nodesTranslator = new NodesTranslator({ dispatcher, nodeTranslator }); const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -173,7 +160,7 @@ test('Only the latest translation will be applied to the node', async () => { // this translation completes within 300 ms, do not wait for completion await delay(100); - expect(translator).toBeCalledTimes(1); + expect(translator).toHaveBeenCalledTimes(1); expect(div.getAttribute('title')).toBe(text); const text1 = 'you must translate me'; @@ -181,7 +168,7 @@ test('Only the latest translation will be applied to the node', async () => { // this translation completes in 100 ms, wait for it await delay(110); - expect(translator).toBeCalledTimes(2); + expect(translator).toHaveBeenCalledTimes(2); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(div.getAttribute('title')).toMatch(text1); From 139839a09220af2b317d30c318f507f490a055c3 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 24 May 2025 00:37:28 +0200 Subject: [PATCH 208/313] chore: fix typo --- src/NodesTranslator.ts | 6 +++--- src/TranslationDispatcher.ts | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index d5911ec..4e8f3b1 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -47,7 +47,7 @@ export class NodesTranslator { this.dispatcher.restoreNode(target); }); observer.addHandler('characterData', ({ target }) => { - // skip this update because it was triggered by the translation itself + // skip this update if it was triggered by the translation itself if (this.shouldSkipNode(target)) { this.translatedNodes.delete(target); return; @@ -64,13 +64,13 @@ export class NodesTranslator { // NOTE: If need delete untracked nodes, we should keep relates like Element -> attributes if (!this.dispatcher.hasNode(attribute)) { - // if node was replaces delete form storage if (oldValue === attribute.value) { + // if the node was replaced but has the same value, delete the old attribute from storage this.translatedNodes.delete(attribute); } this.dispatcher.translateNode(attribute, this.saveTranslatedNode); } else { - // skip this update because it was triggered by the translation itself + // skip this update if it was triggered by the translation itself if (this.shouldSkipNode(attribute)) { this.translatedNodes.delete(attribute); return; diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 59d2736..acd044a 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -22,7 +22,7 @@ export class TranslationDispatcher { filter: TranslatableNodePredicate; nodeTranslator: DOMNodesTranslator; /** - * If nodeIntersectionObserver is passed then node can be translate delayed - after intersect viewport + * If nodeIntersectionObserver is provided, nodes can be translated delayed - after intersect the viewport */ nodeIntersectionObserver?: NodesIntersectionObserver; }) { @@ -42,7 +42,6 @@ export class TranslationDispatcher { * Translates the node and all its nested translatable nodes (text and attribute nodes) */ public translateNode(node: Node, callback: NodeTranslationHandler) { - // Skip node if it does not satisfy the filter if (!this.filter(node)) return; // Translate all nodes which element contains (text nodes and attributes of current and inner elements) From 00afbacec48d217ca511e7def911ecf11f4fb16d Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 24 May 2025 00:40:07 +0200 Subject: [PATCH 209/313] chore: move type --- src/DOMNodesTranslator.ts | 2 +- src/NodesTranslator.ts | 2 -- src/TranslationDispatcher.ts | 3 ++- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 30845b6..30760db 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -1,5 +1,5 @@ +import { NodeTranslationHandler } from './TranslationDispatcher'; import { isInViewport } from './utils/isInViewport'; -import { NodeTranslationHandler } from '.'; export type TranslatorInterface = (text: string, priority: number) => Promise; diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 4e8f3b1..ff24dcd 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -6,8 +6,6 @@ import { DOMNodesTranslator } from '.'; // TODO: scan nodes lazy - defer scan to `requestIdleCallback` instead of instant scan // TODO: describe nodes life cycle -export type NodeTranslationHandler = (node: Node) => void; - /** * Module for dynamic translate a DOM nodes */ diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index acd044a..e8797f9 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -1,7 +1,8 @@ import { DOMNodesTranslator } from './DOMNodesTranslator'; import { NodesIntersectionObserver } from './NodesIntersectionObserver'; import { visitWholeTree } from './utils/visitWholeTree'; -import { NodeTranslationHandler } from '.'; + +export type NodeTranslationHandler = (node: Node) => void; export type TranslatableNodePredicate = (node: Node) => boolean; From e81801a78a3b86478dd1833c55858845b074be70 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 24 May 2025 01:01:10 +0200 Subject: [PATCH 210/313] test: improve name, fix typo --- src/__tests__/DOMNodesTranslator.test.ts | 26 +++++++++++------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 7b12444..56cc2c4 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -3,7 +3,6 @@ import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from test('Translates a node and restores the original node text', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); - const nodeText = 'Hello world!'; const div = document.createElement('div'); div.textContent = nodeText; @@ -16,9 +15,8 @@ test('Translates a node and restores the original node text', async () => { expect(div.textContent).toBe(nodeText); }); -test('Stores original text on translation and clears it on restoration', async () => { +test('Stores original text on translation and clears it after restoration', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); - const nodeText = 'Hello world!'; const div = document.createElement('div'); div.textContent = nodeText; @@ -30,7 +28,7 @@ test('Stores original text on translation and clears it on restoration', async ( await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toEqual(nodeText); + expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toBe(nodeText); // after restore domNodesTranslator.restoreNode(div.childNodes[0]); @@ -38,9 +36,8 @@ test('Stores original text on translation and clears it on restoration', async ( expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); }); -test('Stores node during translation and removes it upon restoration', async () => { +test('Stores the node after translation and removes it after restoration', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); - const div = document.createElement('div'); const nodeText = 'Hello world!'; div.textContent = nodeText; @@ -58,9 +55,8 @@ test('Stores node during translation and removes it upon restoration', async () expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(false); }); -test('Updates translation when attribute value changes', async () => { +test('UpdateNode method translates the modified node', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); - const node = document.createElement('a'); const text = 'title text'; node.setAttribute('title', text); @@ -68,20 +64,23 @@ test('Updates translation when attribute value changes', async () => { // translate domNodesTranslator.translateNode(node.attributes[0], () => {}); await awaitTranslation(); - expect(node.attributes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(node.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); // update value const text1 = 'title text is update'; node.setAttribute('title', text1); + domNodesTranslator.updateNode(node.attributes[0], () => {}); await awaitTranslation(); - expect(node.attributes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(node.attributes[0].textContent).toMatch(text); + expect(node.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(node.getAttribute('title')).toMatch(text1); + + domNodesTranslator.restoreNode(node.attributes[0]); + expect(node.getAttribute('title')).toBe(text1); }); test('Restores the most recent original text after multiple translations', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); - const div = document.createElement('div'); const nodeText = 'Hello world!'; div.textContent = nodeText; @@ -90,14 +89,13 @@ test('Restores the most recent original text after multiple translations', async await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - // change text + // change const nodeText1 = 'My name is Jake'; div.textContent = nodeText1; domNodesTranslator.translateNode(div.childNodes[0], () => {}); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - // restore: elements have the last updated text and are not translated domNodesTranslator.restoreNode(div.childNodes[0]); expect(div.textContent).toBe(nodeText1); }); From 049cbebd4bbc163e6abf541c976f5a857a879169 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 24 May 2025 01:14:03 +0200 Subject: [PATCH 211/313] test: fix typo --- src/__tests__/TranslationDispatcher.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 7b8d101..7d4e6e4 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -33,7 +33,7 @@ test('In lazy-translation mode a non-intersecting node translates immediately', select.appendChild(option); document.body.appendChild(select); - // options not intersect viewport + // element is outside the viewport // IntersectionObserver should not invoke the callback until the node appears in the viewport mockBoundingClientRect(option, { width: 50, height: 100, x: 0, y: 300 }); mockBoundingClientRect(document.body, { width: 100, height: 200, x: 0, y: 0 }); @@ -52,8 +52,7 @@ test('In lazy-translation mode a node not attached to the body translates immedi nodeIntersectionObserver: new NodesIntersectionObserver(), }); - // the node is not in document.body, it is not intersecteble and cannot be translated later. - // translation must happen immediately + // the node is outside the document.body, it is not intersecteble and cannot be translated later const head = document.createElement('head'); const title = document.createElement('title'); const text = 'Title can contain only text'; From 62427fe56b81929cf8a07ecb70d89a2b638566ad Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 24 May 2025 01:25:48 +0200 Subject: [PATCH 212/313] test: fix typo --- src/__tests__/NodesTranslator.preventRecursion.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index f2dbad1..4dc53c1 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -66,7 +66,7 @@ test('Translation of added nodes does not trigger recursive updateNode calls', a expect(updateNodeSpy.mock.calls).toEqual([]); }); -test('Updating node does not trigger recursive updateNode calls', async () => { +test('Updating a node does not trigger recursive updateNode calls', async () => { const { dispatcher, nodeTranslator } = buildTranslationServices(translator); const nodesTranslator = new NodesTranslator({ dispatcher, nodeTranslator }); const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); @@ -98,7 +98,7 @@ test('Updating node does not trigger recursive updateNode calls', async () => { expect(div.getAttribute('title')).toBe(text1); }); -test('Updating a node with a translated-looking value triggers updateNode calls', async () => { +test('Updating a node with a translated-looking value not trigger recursive updateNode calls', async () => { const { dispatcher, nodeTranslator } = buildTranslationServices(translator); const nodesTranslator = new NodesTranslator({ dispatcher, nodeTranslator }); const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); @@ -111,8 +111,6 @@ test('Updating a node with a translated-looking value triggers updateNode calls' nodesTranslator.observe(div); await awaitTranslation(); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); - - // updateNode should not be called await awaitTranslation(); expect(updateNodeSpy.mock.calls).toEqual([]); @@ -134,6 +132,7 @@ test('Updating a node with a translated-looking value triggers updateNode calls' }); test('Only the latest translation will be applied to the node', async () => { + // first call resolves after 300 ms, second call — after 100 ms const translator = vi .fn() .mockImplementationOnce( From df844b0d454dbd9774eb60e81b03f93f54f4917b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 24 May 2025 01:30:38 +0200 Subject: [PATCH 213/313] chore: remove unnecessary code --- src/__tests__/TranslationDispatcher.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 7d4e6e4..ddbb357 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -13,7 +13,6 @@ import { require('intersection-observer'); beforeEach(() => { - mockBoundingClientRect(document.body, { width: 0, height: 0, x: 0, y: 0 }); document.body.innerHTML = ''; }); From 06f700be64791d02cad268dea098da5b2082cd48 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 24 May 2025 01:48:49 +0200 Subject: [PATCH 214/313] test: add --- src/__tests__/DOMNodesTranslator.test.ts | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 56cc2c4..972f563 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -15,6 +15,36 @@ test('Translates a node and restores the original node text', async () => { expect(div.textContent).toBe(nodeText); }); +test('The passed callback is called with the translated node', async () => { + const callback = vi.fn(); + const domNodesTranslator = new DOMNodesTranslator(translator); + const text = 'Hello world!'; + const div = document.createElement('div'); + div.setAttribute('title', text); + + // translate + domNodesTranslator.translateNode(div.attributes[0], callback); + await awaitTranslation(); + + expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + // callback is called with translated node + expect(callback.mock.calls[0][0].nodeValue).toMatch( + containsRegex(TRANSLATION_SYMBOL), + ); + + // update + const text1 = 'update'; + div.setAttribute('title', text1); + domNodesTranslator.updateNode(div.attributes[0], callback); + await awaitTranslation(); + + expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(callback.mock.calls[0][0].nodeValue).toMatch( + containsRegex(TRANSLATION_SYMBOL), + ); + expect(callback.mock.calls[0][0].nodeValue).toMatch(text1); +}); + test('Stores original text on translation and clears it after restoration', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const nodeText = 'Hello world!'; From f82c0b0884bebe1f435a57f34d0313a78fc9dc3e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 24 May 2025 01:54:06 +0200 Subject: [PATCH 215/313] Revert "test: add" This reverts commit aacad7ca0a095ea973bf4f6a7d892070f84130b2. --- src/__tests__/DOMNodesTranslator.test.ts | 30 ------------------------ 1 file changed, 30 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 972f563..56cc2c4 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -15,36 +15,6 @@ test('Translates a node and restores the original node text', async () => { expect(div.textContent).toBe(nodeText); }); -test('The passed callback is called with the translated node', async () => { - const callback = vi.fn(); - const domNodesTranslator = new DOMNodesTranslator(translator); - const text = 'Hello world!'; - const div = document.createElement('div'); - div.setAttribute('title', text); - - // translate - domNodesTranslator.translateNode(div.attributes[0], callback); - await awaitTranslation(); - - expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); - // callback is called with translated node - expect(callback.mock.calls[0][0].nodeValue).toMatch( - containsRegex(TRANSLATION_SYMBOL), - ); - - // update - const text1 = 'update'; - div.setAttribute('title', text1); - domNodesTranslator.updateNode(div.attributes[0], callback); - await awaitTranslation(); - - expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(callback.mock.calls[0][0].nodeValue).toMatch( - containsRegex(TRANSLATION_SYMBOL), - ); - expect(callback.mock.calls[0][0].nodeValue).toMatch(text1); -}); - test('Stores original text on translation and clears it after restoration', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const nodeText = 'Hello world!'; From 50b04da4902e69e110c23e21d8b0e1187515be17 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 24 May 2025 19:46:57 +0200 Subject: [PATCH 216/313] test: improve check --- src/__tests__/NodesTranslator.preventRecursion.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index 4dc53c1..46087f7 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -3,6 +3,10 @@ import { TranslationDispatcher } from '../TranslationDispatcher'; import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; import { NodesTranslator, TranslatorInterface } from '..'; +beforeEach(() => { + document.body.innerHTML = ''; +}); + function buildTranslationServices(translator: TranslatorInterface) { const nodeTranslator = new DOMNodesTranslator(translator); const dispatcher = new TranslationDispatcher({ @@ -115,12 +119,11 @@ test('Updating a node with a translated-looking value not trigger recursive upda expect(updateNodeSpy.mock.calls).toEqual([]); // update content, node should be translated without triggering recursion - const text1 = TRANSLATION_SYMBOL + 'title text'; + const text1 = TRANSLATION_SYMBOL + text; div.setAttribute('title', text1); await awaitTranslation(); expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.attributes[0]); - expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(div.getAttribute('title')).toMatch(TRANSLATION_SYMBOL + text1); + expect(div.getAttribute('title')).toBe(TRANSLATION_SYMBOL + text1); // no recursion await awaitTranslation(); From b494de3c74d5208b4a69d04d6546484a66e625e8 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 24 May 2025 19:53:03 +0200 Subject: [PATCH 217/313] chore: rename --- src/DOMNodesTranslator.ts | 8 ++++---- src/TranslationDispatcher.ts | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 30760db..97d69d8 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -1,4 +1,4 @@ -import { NodeTranslationHandler } from './TranslationDispatcher'; +import { NodeTranslatedCallback } from './TranslationDispatcher'; import { isInViewport } from './utils/isInViewport'; export type TranslatorInterface = (text: string, priority: number) => Promise; @@ -74,7 +74,7 @@ export class DOMNodesTranslator { * Translates nodes that contain text (e.g., Text, Attr) * After translation invokes a callback with the translated node */ - public translateNode = (node: Node, callback: NodeTranslationHandler) => { + public translateNode = (node: Node, callback: NodeTranslatedCallback) => { if (this.hasNode(node)) return; // Skip empty text @@ -108,7 +108,7 @@ export class DOMNodesTranslator { * Translates node after it has been modified * After translation invokes a callback with the translated node */ - public updateNode(node: Node, callback: NodeTranslationHandler) { + public updateNode(node: Node, callback: NodeTranslatedCallback) { const nodeData = this.nodeStorage.get(node); if (!nodeData) return; @@ -119,7 +119,7 @@ export class DOMNodesTranslator { /** * Call only for new and updated nodes */ - private translateNodeContent(node: Node, callback: NodeTranslationHandler) { + private translateNodeContent(node: Node, callback: NodeTranslatedCallback) { const nodeData = this.nodeStorage.get(node); if (!nodeData) { throw new Error('Node is not register'); diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index e8797f9..516e2c2 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -2,7 +2,7 @@ import { DOMNodesTranslator } from './DOMNodesTranslator'; import { NodesIntersectionObserver } from './NodesIntersectionObserver'; import { visitWholeTree } from './utils/visitWholeTree'; -export type NodeTranslationHandler = (node: Node) => void; +export type NodeTranslatedCallback = (node: Node) => void; export type TranslatableNodePredicate = (node: Node) => boolean; @@ -32,7 +32,7 @@ export class TranslationDispatcher { this.nodeIntersectionObserver = nodeIntersectionObserver || null; } - public updateNode(node: Node, callback: NodeTranslationHandler) { + public updateNode(node: Node, callback: NodeTranslatedCallback) { this.nodeTranslator.updateNode(node, callback); } @@ -42,7 +42,7 @@ export class TranslationDispatcher { /** * Translates the node and all its nested translatable nodes (text and attribute nodes) */ - public translateNode(node: Node, callback: NodeTranslationHandler) { + public translateNode(node: Node, callback: NodeTranslatedCallback) { if (!this.filter(node)) return; // Translate all nodes which element contains (text nodes and attributes of current and inner elements) From f3ee556a1f84d5ad30b0bb2547320ff8a6d31376 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 24 May 2025 21:09:44 +0200 Subject: [PATCH 218/313] chore: move --- src/DefaultNodesTranslator.ts | 11 +++++------ src/TranslationDispatcher.ts | 2 +- src/__tests__/NodesTranslator.test.ts | 2 +- src/__tests__/TranslationDispatcher.test.ts | 2 +- src/index.ts | 2 +- .../NodesIntersectionObserver.test.ts | 4 ++-- src/{ => lib}/NodesIntersectionObserver.ts | 2 +- 7 files changed, 12 insertions(+), 13 deletions(-) rename src/{__tests__ => lib}/NodesIntersectionObserver.test.ts (98%) rename src/{ => lib}/NodesIntersectionObserver.ts (97%) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 7c4f6ce..0061244 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -1,12 +1,11 @@ -import { TranslatorInterface } from './DOMNodesTranslator'; -import { NodesTranslator } from './NodesTranslator'; -import { configureTranslatableNodePredicate } from './utils/nodes'; +import { DOMNodesTranslator, TranslatorInterface } from './DOMNodesTranslator'; +import { NodesIntersectionObserver } from './lib/NodesIntersectionObserver'; import { - DOMNodesTranslator, - NodesIntersectionObserver, TranslatableNodePredicate, TranslationDispatcher, -} from '.'; +} from './TranslationDispatcher'; +import { configureTranslatableNodePredicate } from './utils/nodes'; +import { NodesTranslator } from '.'; export interface Config { isTranslatableNode?: TranslatableNodePredicate; diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 516e2c2..a94caec 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -1,5 +1,5 @@ import { DOMNodesTranslator } from './DOMNodesTranslator'; -import { NodesIntersectionObserver } from './NodesIntersectionObserver'; +import { NodesIntersectionObserver } from './lib/NodesIntersectionObserver'; import { visitWholeTree } from './utils/visitWholeTree'; export type NodeTranslatedCallback = (node: Node) => void; diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 7292c89..fa27914 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'fs'; import { Config } from '../DefaultNodesTranslator'; import { DOMNodesTranslator, TranslatorInterface } from '../DOMNodesTranslator'; -import { NodesIntersectionObserver } from '../NodesIntersectionObserver'; +import { NodesIntersectionObserver } from '../lib/NodesIntersectionObserver'; import { NodesTranslator } from '../NodesTranslator'; import { TranslationDispatcher } from '../TranslationDispatcher'; import { configureTranslatableNodePredicate, NodesFilterOptions } from '../utils/nodes'; diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index ddbb357..98ddbad 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -1,5 +1,5 @@ import { DOMNodesTranslator } from '../DOMNodesTranslator'; -import { NodesIntersectionObserver } from '../NodesIntersectionObserver'; +import { NodesIntersectionObserver } from '../lib/NodesIntersectionObserver'; import { TranslationDispatcher } from '../TranslationDispatcher'; import { configureTranslatableNodePredicate } from '../utils/nodes'; import { diff --git a/src/index.ts b/src/index.ts index f1c6999..aad340c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export * from './NodesTranslator'; export * from './TranslationDispatcher'; export * from './DOMNodesTranslator'; -export * from './NodesIntersectionObserver'; +export * from './lib/NodesIntersectionObserver'; export * from './DefaultNodesTranslator'; diff --git a/src/__tests__/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts similarity index 98% rename from src/__tests__/NodesIntersectionObserver.test.ts rename to src/lib/NodesIntersectionObserver.test.ts index 9193468..23d8dcd 100644 --- a/src/__tests__/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -1,10 +1,10 @@ -import { NodesIntersectionObserver } from '../NodesIntersectionObserver'; import { awaitTranslation, containsRegex, mockBoundingClientRect, TRANSLATION_SYMBOL, -} from './utils'; +} from '../__tests__/utils'; +import { NodesIntersectionObserver } from './NodesIntersectionObserver'; require('intersection-observer'); diff --git a/src/NodesIntersectionObserver.ts b/src/lib/NodesIntersectionObserver.ts similarity index 97% rename from src/NodesIntersectionObserver.ts rename to src/lib/NodesIntersectionObserver.ts index c917af6..a7962b8 100644 --- a/src/NodesIntersectionObserver.ts +++ b/src/lib/NodesIntersectionObserver.ts @@ -1,4 +1,4 @@ -import { isIntersectableNode } from './utils/isIntersectableNode'; +import { isIntersectableNode } from '../utils/isIntersectableNode'; /** * @returns Returns the node owner element. From dd60526fce0db844816d4779bec9baccfc4e375d Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 3 Jun 2025 20:51:52 +0200 Subject: [PATCH 219/313] refactor: callback is optional --- src/DOMNodesTranslator.ts | 9 +++++---- src/TranslationDispatcher.ts | 4 ++-- src/__tests__/DOMNodesTranslator.test.ts | 14 +++++++------- src/__tests__/TranslationDispatcher.test.ts | 8 ++++---- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 97d69d8..32a74cb 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -74,7 +74,7 @@ export class DOMNodesTranslator { * Translates nodes that contain text (e.g., Text, Attr) * After translation invokes a callback with the translated node */ - public translateNode = (node: Node, callback: NodeTranslatedCallback) => { + public translateNode = (node: Node, callback?: NodeTranslatedCallback) => { if (this.hasNode(node)) return; // Skip empty text @@ -108,7 +108,7 @@ export class DOMNodesTranslator { * Translates node after it has been modified * After translation invokes a callback with the translated node */ - public updateNode(node: Node, callback: NodeTranslatedCallback) { + public updateNode(node: Node, callback?: NodeTranslatedCallback) { const nodeData = this.nodeStorage.get(node); if (!nodeData) return; @@ -119,7 +119,7 @@ export class DOMNodesTranslator { /** * Call only for new and updated nodes */ - private translateNodeContent(node: Node, callback: NodeTranslatedCallback) { + private translateNodeContent(node: Node, callback?: NodeTranslatedCallback) { const nodeData = this.nodeStorage.get(node); if (!nodeData) { throw new Error('Node is not register'); @@ -140,7 +140,8 @@ export class DOMNodesTranslator { actualNodeData.originalText = node.nodeValue !== null ? node.nodeValue : ''; node.nodeValue = text; - callback(node); + + if (callback) callback(node); }); } } diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index a94caec..c0140ae 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -32,7 +32,7 @@ export class TranslationDispatcher { this.nodeIntersectionObserver = nodeIntersectionObserver || null; } - public updateNode(node: Node, callback: NodeTranslatedCallback) { + public updateNode(node: Node, callback?: NodeTranslatedCallback) { this.nodeTranslator.updateNode(node, callback); } @@ -42,7 +42,7 @@ export class TranslationDispatcher { /** * Translates the node and all its nested translatable nodes (text and attribute nodes) */ - public translateNode(node: Node, callback: NodeTranslatedCallback) { + public translateNode(node: Node, callback?: NodeTranslatedCallback) { if (!this.filter(node)) return; // Translate all nodes which element contains (text nodes and attributes of current and inner elements) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 56cc2c4..47eefa4 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -7,7 +7,7 @@ test('Translates a node and restores the original node text', async () => { const div = document.createElement('div'); div.textContent = nodeText; - domNodesTranslator.translateNode(div.childNodes[0], () => {}); + domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); @@ -24,7 +24,7 @@ test('Stores original text on translation and clears it after restoration', asyn // before translation expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); - domNodesTranslator.translateNode(div.childNodes[0], () => {}); + domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); @@ -45,7 +45,7 @@ test('Stores the node after translation and removes it after restoration', async // not exists before translate expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(false); - domNodesTranslator.translateNode(div.childNodes[0], () => {}); + domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(true); @@ -62,7 +62,7 @@ test('UpdateNode method translates the modified node', async () => { node.setAttribute('title', text); // translate - domNodesTranslator.translateNode(node.attributes[0], () => {}); + domNodesTranslator.translateNode(node.attributes[0]); await awaitTranslation(); expect(node.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); @@ -70,7 +70,7 @@ test('UpdateNode method translates the modified node', async () => { const text1 = 'title text is update'; node.setAttribute('title', text1); - domNodesTranslator.updateNode(node.attributes[0], () => {}); + domNodesTranslator.updateNode(node.attributes[0]); await awaitTranslation(); expect(node.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(node.getAttribute('title')).toMatch(text1); @@ -85,14 +85,14 @@ test('Restores the most recent original text after multiple translations', async const nodeText = 'Hello world!'; div.textContent = nodeText; - domNodesTranslator.translateNode(div.childNodes[0], () => {}); + domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); // change const nodeText1 = 'My name is Jake'; div.textContent = nodeText1; - domNodesTranslator.translateNode(div.childNodes[0], () => {}); + domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 98ddbad..df3814b 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -38,7 +38,7 @@ test('In lazy-translation mode a non-intersecting node translates immediately', mockBoundingClientRect(document.body, { width: 100, height: 200, x: 0, y: 0 }); // the element is translated regardless of viewport intersection - translationDispatcher.translateNode(select, () => {}); + translationDispatcher.translateNode(select); await awaitTranslation(); expect(option.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); @@ -58,7 +58,7 @@ test('In lazy-translation mode a node not attached to the body translates immedi title.textContent = text; head.appendChild(title); - translationDispatcher.translateNode(head, () => {}); + translationDispatcher.translateNode(head); await awaitTranslation(); expect(title.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); @@ -78,7 +78,7 @@ test('Translates and restores the element and its child elements', async () => { nodeTranslator: new DOMNodesTranslator(translator), }); - translationDispatcher.translateNode(div, () => {}); + translationDispatcher.translateNode(div); await awaitTranslation(); // check the text on the element itself expect(div.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); @@ -107,7 +107,7 @@ test('Does not translate ignored node', async () => { div.appendChild(comment); document.body.appendChild(div); - translationDispatcher.translateNode(div, () => {}); + translationDispatcher.translateNode(div); await awaitTranslation(); expect(div.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); From 03f68a998a2d778516069cf853bbccfcc83a47e8 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 3 Jun 2025 20:55:05 +0200 Subject: [PATCH 220/313] chore: improve name --- src/DefaultNodesTranslator.ts | 2 +- src/NodesTranslator.ts | 10 ++++---- .../NodesTranslator.preventRecursion.test.ts | 25 +++++++++++++++---- src/__tests__/NodesTranslator.test.ts | 6 ++--- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 0061244..510cfdd 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -37,7 +37,7 @@ export class DefaultNodesTranslator extends NodesTranslator { super({ dispatcher: translatorDispatcher, - nodeTranslator: domNodesTranslator, + nodesTranslator: domNodesTranslator, }); } } diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index ff24dcd..34cb617 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -11,17 +11,17 @@ import { DOMNodesTranslator } from '.'; */ export class NodesTranslator { private readonly dispatcher; - private readonly nodeTranslator; + private readonly nodesTranslator; constructor({ dispatcher, - nodeTranslator, + nodesTranslator: nodeTranslator, }: { dispatcher: TranslationDispatcher; - nodeTranslator: DOMNodesTranslator; + nodesTranslator: DOMNodesTranslator; }) { this.dispatcher = dispatcher; - this.nodeTranslator = nodeTranslator; + this.nodesTranslator = nodeTranslator; } private translatedNodes = new WeakSet(); @@ -92,6 +92,6 @@ export class NodesTranslator { } public getNodeData(node: Node) { - return this.nodeTranslator.getOriginalNodeText(node); + return this.nodesTranslator.getOriginalNodeText(node); } } diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index 46087f7..1e7c391 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -18,7 +18,10 @@ function buildTranslationServices(translator: TranslatorInterface) { test('Translating a node does not trigger recursive updateNode calls', async () => { const { dispatcher, nodeTranslator } = buildTranslationServices(translator); - const nodesTranslator = new NodesTranslator({ dispatcher, nodeTranslator }); + const nodesTranslator = new NodesTranslator({ + dispatcher, + nodesTranslator: nodeTranslator, + }); const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); const div = document.createElement('div'); @@ -35,7 +38,10 @@ test('Translating a node does not trigger recursive updateNode calls', async () test('Translation of added nodes does not trigger recursive updateNode calls', async () => { const { dispatcher, nodeTranslator } = buildTranslationServices(translator); - const nodesTranslator = new NodesTranslator({ dispatcher, nodeTranslator }); + const nodesTranslator = new NodesTranslator({ + dispatcher, + nodesTranslator: nodeTranslator, + }); const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); const div = document.createElement('div'); @@ -72,7 +78,10 @@ test('Translation of added nodes does not trigger recursive updateNode calls', a test('Updating a node does not trigger recursive updateNode calls', async () => { const { dispatcher, nodeTranslator } = buildTranslationServices(translator); - const nodesTranslator = new NodesTranslator({ dispatcher, nodeTranslator }); + const nodesTranslator = new NodesTranslator({ + dispatcher, + nodesTranslator: nodeTranslator, + }); const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); const div = document.createElement('a'); @@ -104,7 +113,10 @@ test('Updating a node does not trigger recursive updateNode calls', async () => test('Updating a node with a translated-looking value not trigger recursive updateNode calls', async () => { const { dispatcher, nodeTranslator } = buildTranslationServices(translator); - const nodesTranslator = new NodesTranslator({ dispatcher, nodeTranslator }); + const nodesTranslator = new NodesTranslator({ + dispatcher, + nodesTranslator: nodeTranslator, + }); const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); const div = document.createElement('a'); @@ -151,7 +163,10 @@ test('Only the latest translation will be applied to the node', async () => { ), ); const { dispatcher, nodeTranslator } = buildTranslationServices(translator); - const nodesTranslator = new NodesTranslator({ dispatcher, nodeTranslator }); + const nodesTranslator = new NodesTranslator({ + dispatcher, + nodesTranslator: nodeTranslator, + }); const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const div = document.createElement('a'); diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index fa27914..561bbc6 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -52,7 +52,7 @@ function buildTranslationServices( }); return { - nodeTranslator: domNodesTranslator, + nodesTranslator: domNodesTranslator, dispatcher: translatorDispatcher, }; } @@ -243,7 +243,7 @@ describe('basic usage', () => { fillDocument(sample); // Translate document - const { dispatcher, nodeTranslator } = buildTranslationServices( + const { dispatcher, nodesTranslator } = buildTranslationServices( translator, { ...options, @@ -259,7 +259,7 @@ describe('basic usage', () => { ); const domTranslator = new NodesTranslator({ dispatcher, - nodeTranslator, + nodesTranslator: nodesTranslator, }); domTranslator.observe(document.documentElement); From 0f5d0e1dea7ff9abd6415c30c73877655bd352c9 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 3 Jun 2025 21:00:17 +0200 Subject: [PATCH 221/313] refactor: filter is optional --- src/TranslationDispatcher.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index c0140ae..b7de9b7 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -20,7 +20,7 @@ export class TranslationDispatcher { nodeTranslator, nodeIntersectionObserver, }: { - filter: TranslatableNodePredicate; + filter?: TranslatableNodePredicate; nodeTranslator: DOMNodesTranslator; /** * If nodeIntersectionObserver is provided, nodes can be translated delayed - after intersect the viewport @@ -32,18 +32,11 @@ export class TranslationDispatcher { this.nodeIntersectionObserver = nodeIntersectionObserver || null; } - public updateNode(node: Node, callback?: NodeTranslatedCallback) { - this.nodeTranslator.updateNode(node, callback); - } - - public hasNode(node: Node) { - return this.nodeTranslator.hasNode(node); - } /** * Translates the node and all its nested translatable nodes (text and attribute nodes) */ public translateNode(node: Node, callback?: NodeTranslatedCallback) { - if (!this.filter(node)) return; + if (this.filter && !this.filter(node)) return; // Translate all nodes which element contains (text nodes and attributes of current and inner elements) if (node instanceof Element) { @@ -56,7 +49,7 @@ export class TranslationDispatcher { // Handle text nodes and attributes - // translate latter if possible + // translate later if possible if (this.nodeIntersectionObserver) { // if node is outside of body (utility tags like meta or title) translate immediately const isAttachedToDOM = node.getRootNode() !== node; @@ -88,4 +81,12 @@ export class TranslationDispatcher { this.nodeTranslator.restoreNode(node); } + + public updateNode(node: Node, callback?: NodeTranslatedCallback) { + this.nodeTranslator.updateNode(node, callback); + } + + public hasNode(node: Node) { + return this.nodeTranslator.hasNode(node); + } } From 99ee396578b3f40602875072c62940bb7dcb7d86 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 3 Jun 2025 21:07:27 +0200 Subject: [PATCH 222/313] chore: improve comment. delete param --- src/TranslationDispatcher.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index b7de9b7..ebdc0f6 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -51,10 +51,13 @@ export class TranslationDispatcher { // translate later if possible if (this.nodeIntersectionObserver) { - // if node is outside of body (utility tags like meta or title) translate immediately + // Check that the node is attached to the DOM. This means the node is accessible by traversing the current DOM + // This check is necessary to avoid lazy translation for nodes that are detached from the DOM, + // since they potentially may never intersect with the viewport + const isAttachedToDOM = node.getRootNode() !== node; if (isAttachedToDOM) { - this.nodeIntersectionObserver.observe(node, (node: Node) => { + this.nodeIntersectionObserver.observe(node, () => { this.nodeTranslator.translateNode(node, callback); }); return; From b2f737a7911f410cfad1a5874c999ed46f4f4e64 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 3 Jun 2025 21:11:53 +0200 Subject: [PATCH 223/313] refactor: improve name --- src/NodesTranslator.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 34cb617..5ebff7d 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -24,9 +24,8 @@ export class NodesTranslator { this.nodesTranslator = nodeTranslator; } - private translatedNodes = new WeakSet(); - private shouldSkipNode = (node: Node) => this.translatedNodes.has(node); - private saveTranslatedNode = (node: Node) => this.translatedNodes.add(node); + private mutatedNodes = new WeakSet(); + private saveTranslatedNode = (node: Node) => this.mutatedNodes.add(node); private readonly observedNodesStorage = new Map(); public observe(node: Element) { @@ -46,8 +45,8 @@ export class NodesTranslator { }); observer.addHandler('characterData', ({ target }) => { // skip this update if it was triggered by the translation itself - if (this.shouldSkipNode(target)) { - this.translatedNodes.delete(target); + if (this.mutatedNodes.has(target)) { + this.mutatedNodes.delete(target); return; } this.dispatcher.updateNode(target, this.saveTranslatedNode); @@ -64,13 +63,13 @@ export class NodesTranslator { if (!this.dispatcher.hasNode(attribute)) { if (oldValue === attribute.value) { // if the node was replaced but has the same value, delete the old attribute from storage - this.translatedNodes.delete(attribute); + this.mutatedNodes.delete(attribute); } this.dispatcher.translateNode(attribute, this.saveTranslatedNode); } else { // skip this update if it was triggered by the translation itself - if (this.shouldSkipNode(attribute)) { - this.translatedNodes.delete(attribute); + if (this.mutatedNodes.has(attribute)) { + this.mutatedNodes.delete(attribute); return; } this.dispatcher.updateNode(attribute, this.saveTranslatedNode); From 8dd1a05677481ba99c56a71ae66e0ffba985aee3 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 3 Jun 2025 21:12:33 +0200 Subject: [PATCH 224/313] refactor: delete unnecessary code --- src/NodesTranslator.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 5ebff7d..3be22e1 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -25,6 +25,7 @@ export class NodesTranslator { } private mutatedNodes = new WeakSet(); + private saveTranslatedNode = (node: Node) => this.mutatedNodes.add(node); private readonly observedNodesStorage = new Map(); @@ -51,7 +52,7 @@ export class NodesTranslator { } this.dispatcher.updateNode(target, this.saveTranslatedNode); }); - observer.addHandler('changeAttribute', ({ target, attributeName, oldValue }) => { + observer.addHandler('changeAttribute', ({ target, attributeName }) => { if (attributeName === undefined || attributeName === null) return; if (!(target instanceof Element)) return; @@ -61,10 +62,6 @@ export class NodesTranslator { // NOTE: If need delete untracked nodes, we should keep relates like Element -> attributes if (!this.dispatcher.hasNode(attribute)) { - if (oldValue === attribute.value) { - // if the node was replaced but has the same value, delete the old attribute from storage - this.mutatedNodes.delete(attribute); - } this.dispatcher.translateNode(attribute, this.saveTranslatedNode); } else { // skip this update if it was triggered by the translation itself From c201805a6fe710a020f728216208c582a1fd8c14 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 3 Jun 2025 21:24:19 +0200 Subject: [PATCH 225/313] refactor: use early return --- src/NodesTranslator.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 3be22e1..f08194b 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -60,15 +60,16 @@ export class NodesTranslator { if (attribute === null) return; + // skip this update if it was triggered by the translation itself + if (this.mutatedNodes.has(attribute)) { + this.mutatedNodes.delete(attribute); + return; + } + // NOTE: If need delete untracked nodes, we should keep relates like Element -> attributes if (!this.dispatcher.hasNode(attribute)) { this.dispatcher.translateNode(attribute, this.saveTranslatedNode); } else { - // skip this update if it was triggered by the translation itself - if (this.mutatedNodes.has(attribute)) { - this.mutatedNodes.delete(attribute); - return; - } this.dispatcher.updateNode(attribute, this.saveTranslatedNode); } }); From 5ecec1372c5064ae67df2cf1861b4b56902abf3c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 3 Jun 2025 22:25:17 +0200 Subject: [PATCH 226/313] test: add --- src/__tests__/DOMNodesTranslator.test.ts | 84 +++++++++++++++++------- src/__tests__/utils.ts | 11 ++++ 2 files changed, 73 insertions(+), 22 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 47eefa4..668dc67 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -1,25 +1,66 @@ import { DOMNodesTranslator } from '../DOMNodesTranslator'; -import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; +import { + awaitTranslation, + containsRegex, + delay, + TRANSLATION_SYMBOL, + translator, + translatorMockWithDelays, +} from './utils'; + +test('Callback is called only after successful translation', async () => { + const callback = vi.fn(); + + const domNodesTranslator = new DOMNodesTranslator(translatorMockWithDelays); + const text = 'Hello world!'; + const div = document.createElement('div'); + div.textContent = text; + + // first translation call resolves after 300 ms, second — after 100 ms + + // Start the first (slow) translation. Do not wait for it to complete yet + // Ensure that the callback has not been called and content is unchanged + domNodesTranslator.translateNode(div.childNodes[0], callback); + await delay(100); + await awaitTranslation(); + expect(callback).toBeCalledTimes(0); + expect(div.textContent).toBe(text); + + // Start the second (fast) translation and wait for it to complete + // Ensure the callback is called and the content is updated + const text2 = 'Hi friends!'; + div.setAttribute('title', text2); + domNodesTranslator.updateNode(div.childNodes[0], callback); + await delay(100); + await awaitTranslation(); + expect(callback).toBeCalledTimes(1); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // Wait for the first (slow) translation to complete, ensure the callback is still called only once. + await delay(200); + await awaitTranslation(); + expect(callback).toBeCalledTimes(1); +}); test('Translates a node and restores the original node text', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); - const nodeText = 'Hello world!'; + const text = 'Hello world!'; const div = document.createElement('div'); - div.textContent = nodeText; + div.textContent = text; domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); domNodesTranslator.restoreNode(div.childNodes[0]); - expect(div.textContent).toBe(nodeText); + expect(div.textContent).toBe(text); }); test('Stores original text on translation and clears it after restoration', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); - const nodeText = 'Hello world!'; + const text = 'Hello world!'; const div = document.createElement('div'); - div.textContent = nodeText; + div.textContent = text; // before translation expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); @@ -28,19 +69,19 @@ test('Stores original text on translation and clears it after restoration', asyn await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toBe(nodeText); + expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toBe(text); // after restore domNodesTranslator.restoreNode(div.childNodes[0]); - expect(div.textContent).toBe(nodeText); + expect(div.textContent).toBe(text); expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); }); test('Stores the node after translation and removes it after restoration', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const div = document.createElement('div'); - const nodeText = 'Hello world!'; - div.textContent = nodeText; + const text = 'Hello world!'; + div.textContent = text; // not exists before translate expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(false); @@ -51,15 +92,15 @@ test('Stores the node after translation and removes it after restoration', async expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(true); domNodesTranslator.restoreNode(div.childNodes[0]); - expect(div.textContent).toBe(nodeText); + expect(div.textContent).toBe(text); expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(false); }); test('UpdateNode method translates the modified node', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const node = document.createElement('a'); - const text = 'title text'; - node.setAttribute('title', text); + const text1 = 'title text'; + node.setAttribute('title', text1); // translate domNodesTranslator.translateNode(node.attributes[0]); @@ -67,35 +108,34 @@ test('UpdateNode method translates the modified node', async () => { expect(node.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); // update value - const text1 = 'title text is update'; - node.setAttribute('title', text1); + const text2 = 'title text is update'; + node.setAttribute('title', text2); domNodesTranslator.updateNode(node.attributes[0]); await awaitTranslation(); expect(node.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(node.getAttribute('title')).toMatch(text1); + expect(node.getAttribute('title')).toMatch(text2); domNodesTranslator.restoreNode(node.attributes[0]); - expect(node.getAttribute('title')).toBe(text1); + expect(node.getAttribute('title')).toBe(text2); }); test('Restores the most recent original text after multiple translations', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const div = document.createElement('div'); - const nodeText = 'Hello world!'; - div.textContent = nodeText; + div.textContent = 'Hello world!'; domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); // change - const nodeText1 = 'My name is Jake'; - div.textContent = nodeText1; + const text = 'My name is Jake'; + div.textContent = text; domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); domNodesTranslator.restoreNode(div.childNodes[0]); - expect(div.textContent).toBe(nodeText1); + expect(div.textContent).toBe(text); }); diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts index 2336a37..3b95e1a 100644 --- a/src/__tests__/utils.ts +++ b/src/__tests__/utils.ts @@ -33,3 +33,14 @@ export const mockBoundingClientRect = ( }), }); }; + +export const translatorMockWithDelays = vi + .fn() + .mockImplementationOnce( + (text: string) => + new Promise((resolve) => setTimeout(() => resolve(translator(text)), 300)), + ) + .mockImplementationOnce( + (text: string) => + new Promise((resolve) => setTimeout(() => resolve(translator(text)), 100)), + ); From 3124169ee0fa084b9516092a8321e343d51e9b43 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 3 Jun 2025 22:40:21 +0200 Subject: [PATCH 227/313] test: improve --- .../NodesTranslator.preventRecursion.test.ts | 132 ++++++++---------- 1 file changed, 56 insertions(+), 76 deletions(-) diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index 1e7c391..1942d37 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -1,10 +1,18 @@ import { DOMNodesTranslator } from '../DOMNodesTranslator'; import { TranslationDispatcher } from '../TranslationDispatcher'; -import { awaitTranslation, containsRegex, TRANSLATION_SYMBOL, translator } from './utils'; +import { + awaitTranslation, + containsRegex, + delay, + TRANSLATION_SYMBOL, + translator, + translatorMockWithDelays, +} from './utils'; import { NodesTranslator, TranslatorInterface } from '..'; beforeEach(() => { document.body.innerHTML = ''; + vi.clearAllMocks(); }); function buildTranslationServices(translator: TranslatorInterface) { @@ -13,40 +21,37 @@ function buildTranslationServices(translator: TranslatorInterface) { filter: () => true, nodeTranslator: nodeTranslator, }); - return { dispatcher, nodeTranslator }; -} -test('Translating a node does not trigger recursive updateNode calls', async () => { - const { dispatcher, nodeTranslator } = buildTranslationServices(translator); const nodesTranslator = new NodesTranslator({ dispatcher, nodesTranslator: nodeTranslator, }); + return { dispatcher, nodesTranslator }; +} + +test('Translating a node does not trigger recursive updateNode calls', async () => { + const { nodesTranslator, dispatcher } = buildTranslationServices(translator); const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); const div = document.createElement('div'); - const text = 'simple short text'; - div.textContent = text; + div.textContent = 'simple short text'; document.body.appendChild(div); nodesTranslator.observe(div); await awaitTranslation(); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // wait the update call trigger await awaitTranslation(); expect(updateNodeSpy.mock.calls).toEqual([]); }); test('Translation of added nodes does not trigger recursive updateNode calls', async () => { - const { dispatcher, nodeTranslator } = buildTranslationServices(translator); - const nodesTranslator = new NodesTranslator({ - dispatcher, - nodesTranslator: nodeTranslator, - }); + const { nodesTranslator, dispatcher } = buildTranslationServices(translator); const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); const div = document.createElement('div'); - const text = 'Siple short text'; - div.textContent = text; + div.textContent = 'Siple short text'; document.body.appendChild(div); nodesTranslator.observe(div); @@ -57,8 +62,7 @@ test('Translation of added nodes does not trigger recursive updateNode calls', a // add new element const div1 = document.createElement('div'); - const text1 = 'Not a rectangle, but a square'; - div1.textContent = text1; + div1.textContent = 'New simple text'; div.appendChild(div1); await awaitTranslation(); @@ -67,8 +71,7 @@ test('Translation of added nodes does not trigger recursive updateNode calls', a expect(updateNodeSpy.mock.calls).toEqual([]); // add new attribute - const text2 = 'Short text'; - div.setAttribute('title', text2); + div.setAttribute('title', 'Short simple text'); await awaitTranslation(); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); @@ -77,16 +80,11 @@ test('Translation of added nodes does not trigger recursive updateNode calls', a }); test('Updating a node does not trigger recursive updateNode calls', async () => { - const { dispatcher, nodeTranslator } = buildTranslationServices(translator); - const nodesTranslator = new NodesTranslator({ - dispatcher, - nodesTranslator: nodeTranslator, - }); + const { nodesTranslator, dispatcher } = buildTranslationServices(translator); const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); const div = document.createElement('a'); - const text = 'title text'; - div.setAttribute('title', text); + div.setAttribute('title', 'title text'); document.body.appendChild(div); nodesTranslator.observe(div); @@ -96,32 +94,28 @@ test('Updating a node does not trigger recursive updateNode calls', async () => expect(updateNodeSpy.mock.calls).toEqual([]); // update content, node should be translated without triggering recursion - const text1 = 'new Text'; - div.setAttribute('title', text1); + const text = 'new Text'; + div.setAttribute('title', text); await awaitTranslation(); expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.attributes[0]); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(div.getAttribute('title')).toMatch(text1); + expect(div.getAttribute('title')).toMatch(text); // no recursion await awaitTranslation(); expect(updateNodeSpy).toBeCalledTimes(1); nodesTranslator.unobserve(div); - expect(div.getAttribute('title')).toBe(text1); + expect(div.getAttribute('title')).toBe(text); }); test('Updating a node with a translated-looking value not trigger recursive updateNode calls', async () => { - const { dispatcher, nodeTranslator } = buildTranslationServices(translator); - const nodesTranslator = new NodesTranslator({ - dispatcher, - nodesTranslator: nodeTranslator, - }); + const { nodesTranslator, dispatcher } = buildTranslationServices(translator); const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); const div = document.createElement('a'); - const text = 'title text'; - div.setAttribute('title', text); + const text1 = 'title text'; + div.setAttribute('title', text1); document.body.appendChild(div); nodesTranslator.observe(div); @@ -131,11 +125,11 @@ test('Updating a node with a translated-looking value not trigger recursive upda expect(updateNodeSpy.mock.calls).toEqual([]); // update content, node should be translated without triggering recursion - const text1 = TRANSLATION_SYMBOL + text; - div.setAttribute('title', text1); + const text2 = TRANSLATION_SYMBOL + text1; + div.setAttribute('title', text2); await awaitTranslation(); expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.attributes[0]); - expect(div.getAttribute('title')).toBe(TRANSLATION_SYMBOL + text1); + expect(div.getAttribute('title')).toBe(TRANSLATION_SYMBOL + text2); // no recursion await awaitTranslation(); @@ -143,57 +137,43 @@ test('Updating a node with a translated-looking value not trigger recursive upda // restored node has the latest text nodesTranslator.unobserve(div); - expect(div.getAttribute('title')).toBe(text1); + expect(div.getAttribute('title')).toBe(text2); }); test('Only the latest translation will be applied to the node', async () => { - // first call resolves after 300 ms, second call — after 100 ms - const translator = vi - .fn() - .mockImplementationOnce( - (text: string) => - new Promise((resolve) => - setTimeout(() => resolve((text += TRANSLATION_SYMBOL)), 300), - ), - ) - .mockImplementationOnce( - (text: string) => - new Promise((resolve) => - setTimeout(() => resolve((text += TRANSLATION_SYMBOL)), 100), - ), - ); - const { dispatcher, nodeTranslator } = buildTranslationServices(translator); - const nodesTranslator = new NodesTranslator({ - dispatcher, - nodesTranslator: nodeTranslator, - }); - const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + const { nodesTranslator } = buildTranslationServices(translatorMockWithDelays); const div = document.createElement('a'); - const text = 'title text'; - div.setAttribute('title', text); + const text1 = 'title text'; + div.setAttribute('title', text1); document.body.appendChild(div); nodesTranslator.observe(div); - // this translation completes within 300 ms, do not wait for completion - await delay(100); - expect(translator).toHaveBeenCalledTimes(1); - expect(div.getAttribute('title')).toBe(text); + // first translation call resolves after 300 ms, second — after 100 ms - const text1 = 'you must translate me'; - div.setAttribute('title', text1); + // Start the first (slow) translation. Do not wait for it to complete yet + // Ensure that the callback has not been called and content is unchanged + await awaitTranslation(); + expect(translatorMockWithDelays).toHaveBeenCalledTimes(1); + expect(div.getAttribute('title')).toBe(text1); - // this translation completes in 100 ms, wait for it - await delay(110); - expect(translator).toHaveBeenCalledTimes(2); + // Start the second (fast) translation and wait for it to complete + // Ensure the callback is called and the content is updated + const text2 = 'you must translate me'; + div.setAttribute('title', text2); + await delay(100); + await awaitTranslation(); + + expect(translatorMockWithDelays).toHaveBeenCalledTimes(2); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(div.getAttribute('title')).toMatch(text1); + expect(div.getAttribute('title')).toMatch(text2); - // wait for the first translation to finish, it does not modify the node + // Wait for the first (slow) translation to complete, ensure the callback is still called only once. await delay(200); - expect(div.getAttribute('title')).toMatch(text1); + await awaitTranslation(); + expect(div.getAttribute('title')).toMatch(text2); // reset nodesTranslator.unobserve(div); - expect(div.getAttribute('title')).toBe(text1); + expect(div.getAttribute('title')).toBe(text2); }); From ee7a6fb776b882d6732474936f4c41f96bc523bf Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 3 Jun 2025 22:56:15 +0200 Subject: [PATCH 228/313] refactor: clearing store --- src/NodesTranslator.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index f08194b..07cbb32 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,5 +1,6 @@ import { XMutationObserver } from './lib/XMutationObserver'; import { TranslationDispatcher } from './TranslationDispatcher'; +import { visitWholeTree } from './utils/visitWholeTree'; import { DOMNodesTranslator } from '.'; // TODO: consider local language definitions (and implement `from`, `to` parameters for translator to specify default or locale languages) @@ -83,6 +84,11 @@ export class NodesTranslator { throw new Error('Node is not under observe'); } + // if clearing only remove the nodes related to the given node. + visitWholeTree(node, (node) => { + this.mutatedNodes.delete(node); + }); + this.dispatcher.restoreNode(node); this.observedNodesStorage.get(node)?.disconnect(); this.observedNodesStorage.delete(node); From 4c9c48b3654ee16e1bdfc6adec13a2012e624b5b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 4 Jun 2025 14:00:54 +0200 Subject: [PATCH 229/313] chore: rename --- src/DefaultNodesTranslator.ts | 2 +- src/TranslationDispatcher.ts | 18 +++++++-------- .../NodesTranslator.preventRecursion.test.ts | 6 ++--- src/__tests__/NodesTranslator.test.ts | 2 +- src/__tests__/TranslationDispatcher.test.ts | 23 +++++++++---------- 5 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 510cfdd..6a197bc 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -31,7 +31,7 @@ export class DefaultNodesTranslator extends NodesTranslator { const translatorDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, - nodeTranslator: domNodesTranslator, + nodesTranslator: domNodesTranslator, nodeIntersectionObserver, }); diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index ebdc0f6..2712b56 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -12,23 +12,23 @@ export type TranslatableNodePredicate = (node: Node) => boolean; */ export class TranslationDispatcher { private readonly filter; - private readonly nodeTranslator; + private readonly nodesTranslator; private readonly nodeIntersectionObserver; constructor({ filter, - nodeTranslator, + nodesTranslator: nodesTranslator, nodeIntersectionObserver, }: { filter?: TranslatableNodePredicate; - nodeTranslator: DOMNodesTranslator; + nodesTranslator: DOMNodesTranslator; /** * If nodeIntersectionObserver is provided, nodes can be translated delayed - after intersect the viewport */ nodeIntersectionObserver?: NodesIntersectionObserver; }) { this.filter = filter; - this.nodeTranslator = nodeTranslator; + this.nodesTranslator = nodesTranslator; this.nodeIntersectionObserver = nodeIntersectionObserver || null; } @@ -58,13 +58,13 @@ export class TranslationDispatcher { const isAttachedToDOM = node.getRootNode() !== node; if (isAttachedToDOM) { this.nodeIntersectionObserver.observe(node, () => { - this.nodeTranslator.translateNode(node, callback); + this.nodesTranslator.translateNode(node, callback); }); return; } } // translate immediately - this.nodeTranslator.translateNode(node, callback); + this.nodesTranslator.translateNode(node, callback); } /** @@ -82,14 +82,14 @@ export class TranslationDispatcher { this.nodeIntersectionObserver.unobserve(node); } - this.nodeTranslator.restoreNode(node); + this.nodesTranslator.restoreNode(node); } public updateNode(node: Node, callback?: NodeTranslatedCallback) { - this.nodeTranslator.updateNode(node, callback); + this.nodesTranslator.updateNode(node, callback); } public hasNode(node: Node) { - return this.nodeTranslator.hasNode(node); + return this.nodesTranslator.hasNode(node); } } diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index 1942d37..5456022 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -16,15 +16,15 @@ beforeEach(() => { }); function buildTranslationServices(translator: TranslatorInterface) { - const nodeTranslator = new DOMNodesTranslator(translator); + const domNodeTranslator = new DOMNodesTranslator(translator); const dispatcher = new TranslationDispatcher({ filter: () => true, - nodeTranslator: nodeTranslator, + nodesTranslator: domNodeTranslator, }); const nodesTranslator = new NodesTranslator({ dispatcher, - nodesTranslator: nodeTranslator, + nodesTranslator: domNodeTranslator, }); return { dispatcher, nodesTranslator }; } diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 561bbc6..6500cf1 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -47,7 +47,7 @@ function buildTranslationServices( const translatorDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, - nodeTranslator: domNodesTranslator, + nodesTranslator: domNodesTranslator, nodeIntersectionObserver, }); diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index df3814b..a438ce5 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -21,7 +21,7 @@ const isTranslatableNode = () => true; test('In lazy-translation mode a non-intersecting node translates immediately', async () => { const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, - nodeTranslator: new DOMNodesTranslator(translator), + nodesTranslator: new DOMNodesTranslator(translator), nodeIntersectionObserver: new NodesIntersectionObserver(), }); @@ -47,15 +47,14 @@ test('In lazy-translation mode a node not attached to the body translates immedi const domNodesTranslator = new DOMNodesTranslator(translator); const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, - nodeTranslator: domNodesTranslator, + nodesTranslator: domNodesTranslator, nodeIntersectionObserver: new NodesIntersectionObserver(), }); // the node is outside the document.body, it is not intersecteble and cannot be translated later const head = document.createElement('head'); const title = document.createElement('title'); - const text = 'Title can contain only text'; - title.textContent = text; + title.textContent = 'Title can contain only text'; head.appendChild(title); translationDispatcher.translateNode(head); @@ -65,17 +64,17 @@ test('In lazy-translation mode a node not attached to the body translates immedi test('Translates and restores the element and its child elements', async () => { const div = document.createElement('div'); - const text = 'Would you like a cup of tea?'; - div.textContent = text; + const text1 = 'Would you like a cup of tea?'; + div.textContent = text1; const div1 = document.createElement('div'); - const text1 = 'Hi! yes i would'; - div1.textContent = text1; + const text2 = 'Hi! yes i would'; + div1.textContent = text2; div.appendChild(div1); document.body.appendChild(div); const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, - nodeTranslator: new DOMNodesTranslator(translator), + nodesTranslator: new DOMNodesTranslator(translator), }); translationDispatcher.translateNode(div); @@ -85,8 +84,8 @@ test('Translates and restores the element and its child elements', async () => { expect(div1.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); translationDispatcher.restoreNode(div); - expect(div.childNodes[0].textContent).toBe(text); - expect(div1.childNodes[0].textContent).toBe(text1); + expect(div.childNodes[0].textContent).toBe(text1); + expect(div1.childNodes[0].textContent).toBe(text2); }); test('Does not translate ignored node', async () => { @@ -95,7 +94,7 @@ test('Does not translate ignored node', async () => { }); const translationDispatcher = new TranslationDispatcher({ filter, - nodeTranslator: new DOMNodesTranslator(translator), + nodesTranslator: new DOMNodesTranslator(translator), }); const div = document.createElement('div'); From 8e80ce1b15f2a9a6fb05fe1213313a223138baa5 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 4 Jun 2025 14:14:05 +0200 Subject: [PATCH 230/313] test: improve test description --- src/__tests__/DOMNodesTranslator.test.ts | 12 +++++++----- .../NodesTranslator.preventRecursion.test.ts | 16 +++++++++------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 668dc67..b7ec644 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -18,25 +18,27 @@ test('Callback is called only after successful translation', async () => { // first translation call resolves after 300 ms, second — after 100 ms - // Start the first (slow) translation. Do not wait for it to complete yet - // Ensure that the callback has not been called and content is unchanged + // first slow translation (300ms) domNodesTranslator.translateNode(div.childNodes[0], callback); + + // waiting 100ms: the translation is not completed yet, callback should not be called await delay(100); await awaitTranslation(); expect(callback).toBeCalledTimes(0); expect(div.textContent).toBe(text); - // Start the second (fast) translation and wait for it to complete - // Ensure the callback is called and the content is updated + // second fast translation (100ms) const text2 = 'Hi friends!'; div.setAttribute('title', text2); domNodesTranslator.updateNode(div.childNodes[0], callback); + + // waiting 100 ms: the translation is complete and the callback should be called await delay(100); await awaitTranslation(); expect(callback).toBeCalledTimes(1); expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - // Wait for the first (slow) translation to complete, ensure the callback is still called only once. + // wait for the first translation to finish. Callback should not be called again await delay(200); await awaitTranslation(); expect(callback).toBeCalledTimes(1); diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index 5456022..727470c 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -147,28 +147,30 @@ test('Only the latest translation will be applied to the node', async () => { const text1 = 'title text'; div.setAttribute('title', text1); document.body.appendChild(div); - nodesTranslator.observe(div); // first translation call resolves after 300 ms, second — after 100 ms - // Start the first (slow) translation. Do not wait for it to complete yet - // Ensure that the callback has not been called and content is unchanged + // first slow translation (300ms) + nodesTranslator.observe(div); + + // waiting 100ms: the translation is not completed yet, callback should not be called + await delay(100); await awaitTranslation(); expect(translatorMockWithDelays).toHaveBeenCalledTimes(1); expect(div.getAttribute('title')).toBe(text1); - // Start the second (fast) translation and wait for it to complete - // Ensure the callback is called and the content is updated + // second fast translation (100ms) const text2 = 'you must translate me'; div.setAttribute('title', text2); + + // waiting 100 ms: the translation is complete and the callback should be called await delay(100); await awaitTranslation(); - expect(translatorMockWithDelays).toHaveBeenCalledTimes(2); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(div.getAttribute('title')).toMatch(text2); - // Wait for the first (slow) translation to complete, ensure the callback is still called only once. + // wait for the first translation to finish. Callback should not be called again await delay(200); await awaitTranslation(); expect(div.getAttribute('title')).toMatch(text2); From d3d89df549ffcc9d935471f67d00976b22258b15 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 4 Jun 2025 14:18:29 +0200 Subject: [PATCH 231/313] chore: improve code style --- src/NodesTranslator.ts | 1 - src/TranslationDispatcher.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 07cbb32..1812581 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -26,7 +26,6 @@ export class NodesTranslator { } private mutatedNodes = new WeakSet(); - private saveTranslatedNode = (node: Node) => this.mutatedNodes.add(node); private readonly observedNodesStorage = new Map(); diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 2712b56..82f023b 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -16,12 +16,12 @@ export class TranslationDispatcher { private readonly nodeIntersectionObserver; constructor({ - filter, nodesTranslator: nodesTranslator, + filter, nodeIntersectionObserver, }: { - filter?: TranslatableNodePredicate; nodesTranslator: DOMNodesTranslator; + filter?: TranslatableNodePredicate; /** * If nodeIntersectionObserver is provided, nodes can be translated delayed - after intersect the viewport */ From 196ebed6c46bb0c14ebf1b092cf83284bd2d695b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 6 Jun 2025 14:21:38 +0200 Subject: [PATCH 232/313] refactor: support element observing --- src/lib/NodesIntersectionObserver.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.ts b/src/lib/NodesIntersectionObserver.ts index a7962b8..3b1c988 100644 --- a/src/lib/NodesIntersectionObserver.ts +++ b/src/lib/NodesIntersectionObserver.ts @@ -2,16 +2,34 @@ import { isIntersectableNode } from '../utils/isIntersectableNode'; /** * @returns Returns the node owner element. + * If the node is an Element, the element itself is returned */ export function getElementOfNode(node: Node) { - return node instanceof Attr ? node.ownerElement : node.parentElement; + // Use type guards because a simple check `node.nodeType === Node.ELEMENT_NODE` + // does not narrow the type in TypeScript — `node` remains of type `Node` + + const isElement = (node: Node): node is Element => { + return node.nodeType === Node.ELEMENT_NODE; + }; + const isAttr = (node: Node): node is Attr => { + return node.nodeType === Node.ATTRIBUTE_NODE; + }; + + if (isElement(node)) { + return node; + } + if (isAttr(node)) { + return node.ownerElement; + } + + return node.parentElement; } type Callback = (node: Node) => void; /** * Observes DOM nodes for intersection with the viewport and triggers callbacks when they become visible. - * WARNING: This class works with nodes (Text, Attr, etc.), not directly with Element nodes. + * This class supports observing both elements and nodes (Text, Attr, etc.) */ export class NodesIntersectionObserver { private readonly intersectionObserver: IntersectionObserver; From 2b9c210bf2adc9051642413067349bd539e13cb5 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 6 Jun 2025 14:55:40 +0200 Subject: [PATCH 233/313] chore: rename, improve docs --- src/lib/NodesIntersectionObserver.ts | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.ts b/src/lib/NodesIntersectionObserver.ts index 3b1c988..bb065db 100644 --- a/src/lib/NodesIntersectionObserver.ts +++ b/src/lib/NodesIntersectionObserver.ts @@ -2,7 +2,7 @@ import { isIntersectableNode } from '../utils/isIntersectableNode'; /** * @returns Returns the node owner element. - * If the node is an Element, the element itself is returned + * For Element, the element itself is returned */ export function getElementOfNode(node: Node) { // Use type guards because a simple check `node.nodeType === Node.ELEMENT_NODE` @@ -29,7 +29,7 @@ type Callback = (node: Node) => void; /** * Observes DOM nodes for intersection with the viewport and triggers callbacks when they become visible. - * This class supports observing both elements and nodes (Text, Attr, etc.) + * Class supports observing both elements and nodes (Text, Attr, etc.) */ export class NodesIntersectionObserver { private readonly intersectionObserver: IntersectionObserver; @@ -59,24 +59,25 @@ export class NodesIntersectionObserver { * Starts observing the node for intersection. * When the owner element of the node intersects the viewport, the callback is invoked. * Then the owner element and all its tracked nodes are automatically removed from observation. + * (Owner element means: element itself for Element, parent element for Text, owner element for Attr) */ public observe(node: Node, callback: Callback) { - const ownerElement = getElementOfNode(node); + const targetElement = getElementOfNode(node); // Immediately invoke the callback if the node has no owner or is not intersectable - if (!ownerElement || !isIntersectableNode(ownerElement)) { + if (!targetElement || !isIntersectableNode(targetElement)) { callback(node); return; } this.nodeCallbacksMap.set(node, callback); - const observedNodes = this.elementNodesMap.get(ownerElement); + const observedNodes = this.elementNodesMap.get(targetElement); if (observedNodes) { observedNodes.add(node); } else { - this.elementNodesMap.set(ownerElement, new Set([node])); - this.intersectionObserver.observe(ownerElement); + this.elementNodesMap.set(targetElement, new Set([node])); + this.intersectionObserver.observe(targetElement); } } @@ -84,9 +85,9 @@ export class NodesIntersectionObserver { * Stops observing the node and removes it from observation */ public unobserve(node: Node) { - const ownerElement = getElementOfNode(node); - if (!ownerElement) return; - const observedNodes = this.elementNodesMap.get(ownerElement); + const targetElement = getElementOfNode(node); + if (!targetElement) return; + const observedNodes = this.elementNodesMap.get(targetElement); if (!observedNodes || !observedNodes.has(node)) return; // remove only the specified node @@ -95,8 +96,8 @@ export class NodesIntersectionObserver { // if no more nodes are tracked under this ownerElement, stop observing it if (observedNodes.size === 0) { - this.elementNodesMap.delete(ownerElement); - this.intersectionObserver.unobserve(ownerElement); + this.elementNodesMap.delete(targetElement); + this.intersectionObserver.unobserve(targetElement); } } From 08f3db2773c2558ed3d1fd615a81d8bcc9ef3d38 Mon Sep 17 00:00:00 2001 From: katsyuta <134226617+katsyuta@users.noreply.github.com> Date: Fri, 6 Jun 2025 19:50:57 +0200 Subject: [PATCH 234/313] Update src/DefaultNodesTranslator.ts Co-authored-by: Robert Vitonsky --- src/DefaultNodesTranslator.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 6a197bc..7fc0a0d 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -20,8 +20,7 @@ export class DefaultNodesTranslator extends NodesTranslator { constructor(translateCallback: TranslatorInterface, config?: Config) { const isTranslatableNode = config?.isTranslatableNode ?? configureTranslatableNodePredicate(); - const lazyTranslate = - config?.lazyTranslate !== undefined ? config?.lazyTranslate : true; + const lazyTranslate = config?.lazyTranslate ?? true; const domNodesTranslator = new DOMNodesTranslator(translateCallback); From 38490bef32ce69b5d88c9e82dd40c042826b0b69 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 6 Jun 2025 15:01:32 +0200 Subject: [PATCH 235/313] chore: fix typo --- src/TranslationDispatcher.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 82f023b..5356c58 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -63,6 +63,7 @@ export class TranslationDispatcher { return; } } + // translate immediately this.nodesTranslator.translateNode(node, callback); } @@ -72,7 +73,7 @@ export class TranslationDispatcher { * @param onlyTarget determines whether only the target node or all its nested nodes will be restored */ public restoreNode(node: Node, onlyTarget = false) { - // Delete all attributes and inner nodes + // Restore all attributes and inner nodes if (node instanceof Element && !onlyTarget) { visitWholeTree(node, (node) => { this.restoreNode(node, true); From 4f30a3307140a24bc9f1a804da6885aa8d270b02 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 6 Jun 2025 15:03:20 +0200 Subject: [PATCH 236/313] chore: move --- src/DOMNodesTranslator.ts | 2 +- src/TranslationDispatcher.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 32a74cb..aafbc14 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -1,6 +1,6 @@ -import { NodeTranslatedCallback } from './TranslationDispatcher'; import { isInViewport } from './utils/isInViewport'; +export type NodeTranslatedCallback = (node: Node) => void; export type TranslatorInterface = (text: string, priority: number) => Promise; interface NodeData { diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 5356c58..fd14bc7 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -1,9 +1,7 @@ -import { DOMNodesTranslator } from './DOMNodesTranslator'; +import { DOMNodesTranslator, NodeTranslatedCallback } from './DOMNodesTranslator'; import { NodesIntersectionObserver } from './lib/NodesIntersectionObserver'; import { visitWholeTree } from './utils/visitWholeTree'; -export type NodeTranslatedCallback = (node: Node) => void; - export type TranslatableNodePredicate = (node: Node) => boolean; /** From f0b4412b25797012b6403c1d11dec6230c144422 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 6 Jun 2025 15:06:33 +0200 Subject: [PATCH 237/313] refactor: remove unnecessary func --- src/NodesTranslator.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 1812581..33658ec 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -25,8 +25,8 @@ export class NodesTranslator { this.nodesTranslator = nodeTranslator; } + // store nodes that were changed (transferred) to us private mutatedNodes = new WeakSet(); - private saveTranslatedNode = (node: Node) => this.mutatedNodes.add(node); private readonly observedNodesStorage = new Map(); public observe(node: Element) { @@ -39,7 +39,9 @@ export class NodesTranslator { this.observedNodesStorage.set(node, observer); observer.addHandler('elementAdded', ({ target }) => { - this.dispatcher.translateNode(target, this.saveTranslatedNode); + this.dispatcher.translateNode(target, (node: Node) => + this.mutatedNodes.add(node), + ); }); observer.addHandler('elementRemoved', ({ target }) => { this.dispatcher.restoreNode(target); @@ -50,7 +52,9 @@ export class NodesTranslator { this.mutatedNodes.delete(target); return; } - this.dispatcher.updateNode(target, this.saveTranslatedNode); + this.dispatcher.updateNode(target, (node: Node) => + this.mutatedNodes.add(node), + ); }); observer.addHandler('changeAttribute', ({ target, attributeName }) => { if (attributeName === undefined || attributeName === null) return; @@ -68,14 +72,18 @@ export class NodesTranslator { // NOTE: If need delete untracked nodes, we should keep relates like Element -> attributes if (!this.dispatcher.hasNode(attribute)) { - this.dispatcher.translateNode(attribute, this.saveTranslatedNode); + this.dispatcher.translateNode(attribute, (node: Node) => + this.mutatedNodes.add(node), + ); } else { - this.dispatcher.updateNode(attribute, this.saveTranslatedNode); + this.dispatcher.updateNode(attribute, (node: Node) => + this.mutatedNodes.add(node), + ); } }); observer.observe(node); - this.dispatcher.translateNode(node, this.saveTranslatedNode); + this.dispatcher.translateNode(node, (node: Node) => this.mutatedNodes.add(node)); } public unobserve(node: Element) { From bbd988eec84fe9fbc94c36e41a7b0eb58c1ec32d Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 6 Jun 2025 20:58:24 +0200 Subject: [PATCH 238/313] refactor: use callback for clean store --- src/NodesTranslator.ts | 7 ++----- src/TranslationDispatcher.ts | 6 ++++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 33658ec..a278cde 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -1,6 +1,5 @@ import { XMutationObserver } from './lib/XMutationObserver'; import { TranslationDispatcher } from './TranslationDispatcher'; -import { visitWholeTree } from './utils/visitWholeTree'; import { DOMNodesTranslator } from '.'; // TODO: consider local language definitions (and implement `from`, `to` parameters for translator to specify default or locale languages) @@ -91,12 +90,10 @@ export class NodesTranslator { throw new Error('Node is not under observe'); } - // if clearing only remove the nodes related to the given node. - visitWholeTree(node, (node) => { + // restore the node and all nested nodes if it’s an element, and remove them from mutatedNodes after unobserve + this.dispatcher.restoreNode(node, (node) => { this.mutatedNodes.delete(node); }); - - this.dispatcher.restoreNode(node); this.observedNodesStorage.get(node)?.disconnect(); this.observedNodesStorage.delete(node); } diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index fd14bc7..49f6b47 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -70,11 +70,11 @@ export class TranslationDispatcher { * Restores the original node text * @param onlyTarget determines whether only the target node or all its nested nodes will be restored */ - public restoreNode(node: Node, onlyTarget = false) { + public restoreNode(node: Node, callback?: (node: Node) => void, onlyTarget = false) { // Restore all attributes and inner nodes if (node instanceof Element && !onlyTarget) { visitWholeTree(node, (node) => { - this.restoreNode(node, true); + this.restoreNode(node, callback, true); }); } if (this.nodeIntersectionObserver) { @@ -82,6 +82,8 @@ export class TranslationDispatcher { } this.nodesTranslator.restoreNode(node); + + if (callback) callback(node); } public updateNode(node: Node, callback?: NodeTranslatedCallback) { From bdcfc9b616fc9e9a88a390a863bd9d0f8721ef28 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 7 Jun 2025 17:14:06 +0200 Subject: [PATCH 239/313] refactor: improve method design --- src/NodesTranslator.ts | 9 ++++++--- src/TranslationDispatcher.ts | 12 ++++++++++-- src/__tests__/TranslationDispatcher.test.ts | 2 +- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index a278cde..69e89c5 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -43,7 +43,7 @@ export class NodesTranslator { ); }); observer.addHandler('elementRemoved', ({ target }) => { - this.dispatcher.restoreNode(target); + this.dispatcher.restoreNode({ node: target }); }); observer.addHandler('characterData', ({ target }) => { // skip this update if it was triggered by the translation itself @@ -91,8 +91,11 @@ export class NodesTranslator { } // restore the node and all nested nodes if it’s an element, and remove them from mutatedNodes after unobserve - this.dispatcher.restoreNode(node, (node) => { - this.mutatedNodes.delete(node); + this.dispatcher.restoreNode({ + node, + callback: (node) => { + this.mutatedNodes.delete(node); + }, }); this.observedNodesStorage.get(node)?.disconnect(); this.observedNodesStorage.delete(node); diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 49f6b47..4eafbbf 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -70,11 +70,19 @@ export class TranslationDispatcher { * Restores the original node text * @param onlyTarget determines whether only the target node or all its nested nodes will be restored */ - public restoreNode(node: Node, callback?: (node: Node) => void, onlyTarget = false) { + public restoreNode({ + node, + callback, + onlyTarget = false, + }: { + node: Node; + callback?: (node: Node) => void; + onlyTarget?: boolean; + }) { // Restore all attributes and inner nodes if (node instanceof Element && !onlyTarget) { visitWholeTree(node, (node) => { - this.restoreNode(node, callback, true); + this.restoreNode({ node, callback, onlyTarget: true }); }); } if (this.nodeIntersectionObserver) { diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index a438ce5..fc1bd0d 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -83,7 +83,7 @@ test('Translates and restores the element and its child elements', async () => { expect(div.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(div1.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - translationDispatcher.restoreNode(div); + translationDispatcher.restoreNode({ node: div }); expect(div.childNodes[0].textContent).toBe(text1); expect(div1.childNodes[0].textContent).toBe(text2); }); From ecd10e473d68d940dcc6700ffcf7e4222f7eb7eb Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 7 Jun 2025 17:41:54 +0200 Subject: [PATCH 240/313] test: remove mock from utils --- src/__tests__/DOMNodesTranslator.test.ts | 86 +++++++++++-------- .../NodesTranslator.preventRecursion.test.ts | 27 ++++-- src/__tests__/utils.ts | 11 --- 3 files changed, 69 insertions(+), 55 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index b7ec644..4107279 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -5,45 +5,8 @@ import { delay, TRANSLATION_SYMBOL, translator, - translatorMockWithDelays, } from './utils'; -test('Callback is called only after successful translation', async () => { - const callback = vi.fn(); - - const domNodesTranslator = new DOMNodesTranslator(translatorMockWithDelays); - const text = 'Hello world!'; - const div = document.createElement('div'); - div.textContent = text; - - // first translation call resolves after 300 ms, second — after 100 ms - - // first slow translation (300ms) - domNodesTranslator.translateNode(div.childNodes[0], callback); - - // waiting 100ms: the translation is not completed yet, callback should not be called - await delay(100); - await awaitTranslation(); - expect(callback).toBeCalledTimes(0); - expect(div.textContent).toBe(text); - - // second fast translation (100ms) - const text2 = 'Hi friends!'; - div.setAttribute('title', text2); - domNodesTranslator.updateNode(div.childNodes[0], callback); - - // waiting 100 ms: the translation is complete and the callback should be called - await delay(100); - await awaitTranslation(); - expect(callback).toBeCalledTimes(1); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - - // wait for the first translation to finish. Callback should not be called again - await delay(200); - await awaitTranslation(); - expect(callback).toBeCalledTimes(1); -}); - test('Translates a node and restores the original node text', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const text = 'Hello world!'; @@ -141,3 +104,52 @@ test('Restores the most recent original text after multiple translations', async domNodesTranslator.restoreNode(div.childNodes[0]); expect(div.textContent).toBe(text); }); + +test('Callback is called only after successful translation', async () => { + // first translation call resolves after 300 ms, second — after 100 ms + const translatorWithDelay = vi + .fn() + .mockImplementationOnce( + (text: string) => + new Promise((resolve) => + setTimeout(() => resolve(translator(text)), 300), + ), + ) + .mockImplementationOnce( + (text: string) => + new Promise((resolve) => + setTimeout(() => resolve(translator(text)), 100), + ), + ); + const callback = vi.fn(); + + const domNodesTranslator = new DOMNodesTranslator(translatorWithDelay); + const div = document.createElement('div'); + const text1 = 'Hello world!'; + div.setAttribute('title', text1); + + // first slow translation (300ms) + domNodesTranslator.translateNode(div.attributes[0], callback); + + // waiting 100ms: the translation is not completed yet, callback should not be called + await delay(100); + await awaitTranslation(); + expect(callback).toBeCalledTimes(0); + expect(div.getAttribute('title')).toBe(text1); + + // second fast translation (100ms) + const text2 = 'Hi friends!'; + div.setAttribute('title', text2); + domNodesTranslator.updateNode(div.attributes[0], callback); + + // waiting 100 ms: the translation is complete and the callback should be called + await delay(100); + await awaitTranslation(); + expect(callback).toBeCalledTimes(1); + expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // wait for the first translation to finish. Callback should not be called again + await delay(200); + await awaitTranslation(); + expect(callback).toBeCalledTimes(1); +}); diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index 727470c..b13faae 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -6,7 +6,6 @@ import { delay, TRANSLATION_SYMBOL, translator, - translatorMockWithDelays, } from './utils'; import { NodesTranslator, TranslatorInterface } from '..'; @@ -141,32 +140,46 @@ test('Updating a node with a translated-looking value not trigger recursive upda }); test('Only the latest translation will be applied to the node', async () => { - const { nodesTranslator } = buildTranslationServices(translatorMockWithDelays); + // first translation call resolves after 300 ms, second — after 100 ms + const translatorWithDelay = vi + .fn() + .mockImplementationOnce( + (text: string) => + new Promise((resolve) => + setTimeout(() => resolve(translator(text)), 300), + ), + ) + .mockImplementationOnce( + (text: string) => + new Promise((resolve) => + setTimeout(() => resolve(translator(text)), 100), + ), + ); + + const { nodesTranslator } = buildTranslationServices(translatorWithDelay); const div = document.createElement('a'); const text1 = 'title text'; div.setAttribute('title', text1); document.body.appendChild(div); - // first translation call resolves after 300 ms, second — after 100 ms - // first slow translation (300ms) nodesTranslator.observe(div); // waiting 100ms: the translation is not completed yet, callback should not be called await delay(100); await awaitTranslation(); - expect(translatorMockWithDelays).toHaveBeenCalledTimes(1); + expect(translatorWithDelay).toHaveBeenCalledTimes(1); expect(div.getAttribute('title')).toBe(text1); // second fast translation (100ms) - const text2 = 'you must translate me'; + const text2 = 'new title text'; div.setAttribute('title', text2); // waiting 100 ms: the translation is complete and the callback should be called await delay(100); await awaitTranslation(); - expect(translatorMockWithDelays).toHaveBeenCalledTimes(2); + expect(translatorWithDelay).toHaveBeenCalledTimes(2); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(div.getAttribute('title')).toMatch(text2); diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts index 3b95e1a..2336a37 100644 --- a/src/__tests__/utils.ts +++ b/src/__tests__/utils.ts @@ -33,14 +33,3 @@ export const mockBoundingClientRect = ( }), }); }; - -export const translatorMockWithDelays = vi - .fn() - .mockImplementationOnce( - (text: string) => - new Promise((resolve) => setTimeout(() => resolve(translator(text)), 300)), - ) - .mockImplementationOnce( - (text: string) => - new Promise((resolve) => setTimeout(() => resolve(translator(text)), 100)), - ); From c091c277b245aba85aba3c7c5c1659de79baf5be Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 7 Jun 2025 18:15:09 +0200 Subject: [PATCH 241/313] chore: improve docs --- src/NodesTranslator.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 69e89c5..031c882 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -24,7 +24,6 @@ export class NodesTranslator { this.nodesTranslator = nodeTranslator; } - // store nodes that were changed (transferred) to us private mutatedNodes = new WeakSet(); private readonly observedNodesStorage = new Map(); @@ -90,7 +89,7 @@ export class NodesTranslator { throw new Error('Node is not under observe'); } - // restore the node and all nested nodes if it’s an element, and remove them from mutatedNodes after unobserve + // restore the node and all nested nodes, and remove them from mutatedNodes this.dispatcher.restoreNode({ node, callback: (node) => { From 93dfb3799590b925fd7d5ed40e03372df1f71dfd Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 7 Jun 2025 18:36:39 +0200 Subject: [PATCH 242/313] test: improve test readable --- src/__tests__/DOMNodesTranslator.test.ts | 11 ++----- .../NodesTranslator.preventRecursion.test.ts | 31 +++---------------- 2 files changed, 8 insertions(+), 34 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 4107279..ae89a17 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -111,15 +111,11 @@ test('Callback is called only after successful translation', async () => { .fn() .mockImplementationOnce( (text: string) => - new Promise((resolve) => - setTimeout(() => resolve(translator(text)), 300), - ), + new Promise((res) => setTimeout(() => res(translator(text)), 300)), ) .mockImplementationOnce( (text: string) => - new Promise((resolve) => - setTimeout(() => resolve(translator(text)), 100), - ), + new Promise((res) => setTimeout(() => res(translator(text)), 100)), ); const callback = vi.fn(); @@ -138,8 +134,7 @@ test('Callback is called only after successful translation', async () => { expect(div.getAttribute('title')).toBe(text1); // second fast translation (100ms) - const text2 = 'Hi friends!'; - div.setAttribute('title', text2); + div.setAttribute('title', 'Hi friends!'); domNodesTranslator.updateNode(div.attributes[0], callback); // waiting 100 ms: the translation is complete and the callback should be called diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index b13faae..e320283 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -28,29 +28,12 @@ function buildTranslationServices(translator: TranslatorInterface) { return { dispatcher, nodesTranslator }; } -test('Translating a node does not trigger recursive updateNode calls', async () => { +test('Translation of nodes does not trigger recursive updateNode calls', async () => { const { nodesTranslator, dispatcher } = buildTranslationServices(translator); const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); const div = document.createElement('div'); - div.textContent = 'simple short text'; - document.body.appendChild(div); - nodesTranslator.observe(div); - - await awaitTranslation(); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - - // wait the update call trigger - await awaitTranslation(); - expect(updateNodeSpy.mock.calls).toEqual([]); -}); - -test('Translation of added nodes does not trigger recursive updateNode calls', async () => { - const { nodesTranslator, dispatcher } = buildTranslationServices(translator); - const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); - - const div = document.createElement('div'); - div.textContent = 'Siple short text'; + div.textContent = 'Simple text'; document.body.appendChild(div); nodesTranslator.observe(div); @@ -61,7 +44,7 @@ test('Translation of added nodes does not trigger recursive updateNode calls', a // add new element const div1 = document.createElement('div'); - div1.textContent = 'New simple text'; + div1.textContent = 'new text'; div.appendChild(div1); await awaitTranslation(); @@ -70,7 +53,7 @@ test('Translation of added nodes does not trigger recursive updateNode calls', a expect(updateNodeSpy.mock.calls).toEqual([]); // add new attribute - div.setAttribute('title', 'Short simple text'); + div.setAttribute('title', 'Short text'); await awaitTranslation(); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); @@ -93,7 +76,7 @@ test('Updating a node does not trigger recursive updateNode calls', async () => expect(updateNodeSpy.mock.calls).toEqual([]); // update content, node should be translated without triggering recursion - const text = 'new Text'; + const text = 'new text'; div.setAttribute('title', text); await awaitTranslation(); expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.attributes[0]); @@ -103,9 +86,6 @@ test('Updating a node does not trigger recursive updateNode calls', async () => // no recursion await awaitTranslation(); expect(updateNodeSpy).toBeCalledTimes(1); - - nodesTranslator.unobserve(div); - expect(div.getAttribute('title')).toBe(text); }); test('Updating a node with a translated-looking value not trigger recursive updateNode calls', async () => { @@ -155,7 +135,6 @@ test('Only the latest translation will be applied to the node', async () => { setTimeout(() => resolve(translator(text)), 100), ), ); - const { nodesTranslator } = buildTranslationServices(translatorWithDelay); const div = document.createElement('a'); From db7b1c341e11bed2abec87a51af17ae91b1589e3 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 7 Jun 2025 18:47:25 +0200 Subject: [PATCH 243/313] test: remove duplicate --- src/__tests__/DOMNodesTranslator.test.ts | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index ae89a17..92ceda3 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -85,26 +85,6 @@ test('UpdateNode method translates the modified node', async () => { expect(node.getAttribute('title')).toBe(text2); }); -test('Restores the most recent original text after multiple translations', async () => { - const domNodesTranslator = new DOMNodesTranslator(translator); - const div = document.createElement('div'); - div.textContent = 'Hello world!'; - - domNodesTranslator.translateNode(div.childNodes[0]); - await awaitTranslation(); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - - // change - const text = 'My name is Jake'; - div.textContent = text; - domNodesTranslator.translateNode(div.childNodes[0]); - await awaitTranslation(); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - - domNodesTranslator.restoreNode(div.childNodes[0]); - expect(div.textContent).toBe(text); -}); - test('Callback is called only after successful translation', async () => { // first translation call resolves after 300 ms, second — after 100 ms const translatorWithDelay = vi From f0b3b76f458c2c7e5fe7449d9dca0b7436863d80 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 7 Jun 2025 18:52:20 +0200 Subject: [PATCH 244/313] test: remove duplicate --- .../NodesTranslator.preventRecursion.test.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index e320283..54367cc 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -20,17 +20,18 @@ function buildTranslationServices(translator: TranslatorInterface) { filter: () => true, nodesTranslator: domNodeTranslator, }); - const nodesTranslator = new NodesTranslator({ dispatcher, nodesTranslator: domNodeTranslator, }); - return { dispatcher, nodesTranslator }; + + const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); + + return { dispatcher, nodesTranslator, updateNodeSpy }; } test('Translation of nodes does not trigger recursive updateNode calls', async () => { - const { nodesTranslator, dispatcher } = buildTranslationServices(translator); - const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); + const { nodesTranslator, updateNodeSpy } = buildTranslationServices(translator); const div = document.createElement('div'); div.textContent = 'Simple text'; @@ -62,8 +63,7 @@ test('Translation of nodes does not trigger recursive updateNode calls', async ( }); test('Updating a node does not trigger recursive updateNode calls', async () => { - const { nodesTranslator, dispatcher } = buildTranslationServices(translator); - const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); + const { nodesTranslator, updateNodeSpy } = buildTranslationServices(translator); const div = document.createElement('a'); div.setAttribute('title', 'title text'); @@ -89,8 +89,7 @@ test('Updating a node does not trigger recursive updateNode calls', async () => }); test('Updating a node with a translated-looking value not trigger recursive updateNode calls', async () => { - const { nodesTranslator, dispatcher } = buildTranslationServices(translator); - const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); + const { nodesTranslator, updateNodeSpy } = buildTranslationServices(translator); const div = document.createElement('a'); const text1 = 'title text'; From fa8f0a0a27b5032a0c34d9bbcbbe4e2b401728d3 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 10 Jun 2025 18:44:49 +0200 Subject: [PATCH 245/313] refactor: remove recusrion and unnecessary param --- src/NodesTranslator.ts | 9 ++---- src/TranslationDispatcher.ts | 34 ++++++++------------- src/__tests__/TranslationDispatcher.test.ts | 2 +- 3 files changed, 17 insertions(+), 28 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 031c882..e86b691 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -42,7 +42,7 @@ export class NodesTranslator { ); }); observer.addHandler('elementRemoved', ({ target }) => { - this.dispatcher.restoreNode({ node: target }); + this.dispatcher.restoreNode(target); }); observer.addHandler('characterData', ({ target }) => { // skip this update if it was triggered by the translation itself @@ -90,11 +90,8 @@ export class NodesTranslator { } // restore the node and all nested nodes, and remove them from mutatedNodes - this.dispatcher.restoreNode({ - node, - callback: (node) => { - this.mutatedNodes.delete(node); - }, + this.dispatcher.restoreNode(node, (node) => { + this.mutatedNodes.delete(node); }); this.observedNodesStorage.get(node)?.disconnect(); this.observedNodesStorage.delete(node); diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 4eafbbf..43fa14a 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -68,30 +68,22 @@ export class TranslationDispatcher { /** * Restores the original node text - * @param onlyTarget determines whether only the target node or all its nested nodes will be restored */ - public restoreNode({ - node, - callback, - onlyTarget = false, - }: { - node: Node; - callback?: (node: Node) => void; - onlyTarget?: boolean; - }) { - // Restore all attributes and inner nodes - if (node instanceof Element && !onlyTarget) { - visitWholeTree(node, (node) => { - this.restoreNode({ node, callback, onlyTarget: true }); - }); - } - if (this.nodeIntersectionObserver) { - this.nodeIntersectionObserver.unobserve(node); - } + public restoreNode(node: Node, callback?: (node: Node) => void) { + const restoreSingleNode = (node: Node) => { + if (this.nodeIntersectionObserver) { + this.nodeIntersectionObserver.unobserve(node); + } + this.nodesTranslator.restoreNode(node); - this.nodesTranslator.restoreNode(node); + if (callback) callback(node); + }; - if (callback) callback(node); + if (node instanceof Element) { + visitWholeTree(node, restoreSingleNode); + } else { + restoreSingleNode(node); + } } public updateNode(node: Node, callback?: NodeTranslatedCallback) { diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index fc1bd0d..a438ce5 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -83,7 +83,7 @@ test('Translates and restores the element and its child elements', async () => { expect(div.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(div1.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - translationDispatcher.restoreNode({ node: div }); + translationDispatcher.restoreNode(div); expect(div.childNodes[0].textContent).toBe(text1); expect(div1.childNodes[0].textContent).toBe(text2); }); From c92306d3d613a461c4018b338040da0ada1f1ddc Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 11 Jun 2025 13:35:56 +0200 Subject: [PATCH 246/313] refactor: remove recursion and improve docs --- src/TranslationDispatcher.ts | 63 ++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 43fa14a..e223246 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -31,43 +31,50 @@ export class TranslationDispatcher { } /** - * Translates the node and all its nested translatable nodes (text and attribute nodes) + * Translates the node and all its nested translatable nodes (Text, Attr, etc.) + * + * @param callback - Optional. Called asynchronously for each translated node, in the same order as nodes are translated. */ public translateNode(node: Node, callback?: NodeTranslatedCallback) { - if (this.filter && !this.filter(node)) return; + // Handle text nodes and attributes + const translateSingleNode = (node: Node) => { + if (this.filter && !this.filter(node)) return; + + // translate later if possible + if (this.nodeIntersectionObserver) { + // Check that the node is attached to the DOM. This means the node is accessible by traversing the current DOM + // This check is necessary to avoid lazy translation for nodes that are detached from the DOM, + // since they potentially may never intersect with the viewport + + const isAttachedToDOM = node.getRootNode() !== node; + if (isAttachedToDOM) { + this.nodeIntersectionObserver.observe(node, () => { + this.nodesTranslator.translateNode(node, callback); + }); + return; + } + } + + // translate immediately + this.nodesTranslator.translateNode(node, callback); + }; // Translate all nodes which element contains (text nodes and attributes of current and inner elements) if (node instanceof Element) { visitWholeTree(node, (node) => { if (node instanceof Element) return; - this.translateNode(node, callback); + translateSingleNode(node); }); - return; - } - - // Handle text nodes and attributes - - // translate later if possible - if (this.nodeIntersectionObserver) { - // Check that the node is attached to the DOM. This means the node is accessible by traversing the current DOM - // This check is necessary to avoid lazy translation for nodes that are detached from the DOM, - // since they potentially may never intersect with the viewport - - const isAttachedToDOM = node.getRootNode() !== node; - if (isAttachedToDOM) { - this.nodeIntersectionObserver.observe(node, () => { - this.nodesTranslator.translateNode(node, callback); - }); - return; - } + } else { + translateSingleNode(node); } - - // translate immediately - this.nodesTranslator.translateNode(node, callback); } /** - * Restores the original node text + * Restores the original node text. + * For elements, restores each child node (Text, Attr, etc.) + * + * @param callback - Optional. Called synchronously after each individual node is restored */ public restoreNode(node: Node, callback?: (node: Node) => void) { const restoreSingleNode = (node: Node) => { @@ -79,6 +86,7 @@ export class TranslationDispatcher { if (callback) callback(node); }; + // restore all nested nodes if (node instanceof Element) { visitWholeTree(node, restoreSingleNode); } else { @@ -86,6 +94,11 @@ export class TranslationDispatcher { } } + /** + * Re-translates a node after it has been modified. + * + * @param callback - Optional. Called after the node has been re-translated. + */ public updateNode(node: Node, callback?: NodeTranslatedCallback) { this.nodesTranslator.updateNode(node, callback); } From 04d862646636a327d760dbb43e6f2413f90ffc0a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 11 Jun 2025 14:08:07 +0200 Subject: [PATCH 247/313] chore: improve style --- src/DefaultNodesTranslator.ts | 4 +- src/TranslationDispatcher.ts | 47 ++++++++++++--------- src/__tests__/TranslationDispatcher.test.ts | 4 +- 3 files changed, 30 insertions(+), 25 deletions(-) diff --git a/src/DefaultNodesTranslator.ts b/src/DefaultNodesTranslator.ts index 7fc0a0d..02d801d 100644 --- a/src/DefaultNodesTranslator.ts +++ b/src/DefaultNodesTranslator.ts @@ -24,14 +24,14 @@ export class DefaultNodesTranslator extends NodesTranslator { const domNodesTranslator = new DOMNodesTranslator(translateCallback); - const nodeIntersectionObserver = lazyTranslate + const nodesIntersectionObserver = lazyTranslate ? new NodesIntersectionObserver() : undefined; const translatorDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodesTranslator: domNodesTranslator, - nodeIntersectionObserver, + nodesIntersectionObserver, }); super({ diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index e223246..3bef9b3 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -11,44 +11,48 @@ export type TranslatableNodePredicate = (node: Node) => boolean; export class TranslationDispatcher { private readonly filter; private readonly nodesTranslator; - private readonly nodeIntersectionObserver; + private readonly nodesIntersectionObserver; constructor({ - nodesTranslator: nodesTranslator, + nodesTranslator, filter, - nodeIntersectionObserver, + nodesIntersectionObserver, }: { nodesTranslator: DOMNodesTranslator; + /** + * Determines which nodes should be translated + */ filter?: TranslatableNodePredicate; /** - * If nodeIntersectionObserver is provided, nodes can be translated delayed - after intersect the viewport + * If nodesIntersectionObserver is provided, nodes can be translated delayed - after intersect the viewport */ - nodeIntersectionObserver?: NodesIntersectionObserver; + nodesIntersectionObserver?: NodesIntersectionObserver; }) { this.filter = filter; this.nodesTranslator = nodesTranslator; - this.nodeIntersectionObserver = nodeIntersectionObserver || null; + this.nodesIntersectionObserver = nodesIntersectionObserver; } /** * Translates the node and all its nested translatable nodes (Text, Attr, etc.) * - * @param callback - Optional. Called asynchronously for each translated node, in the same order as nodes are translated. + * @param [callback] - Called asynchronously for each translated node, in the same order as nodes are translated. + * The callback receives the translated node */ public translateNode(node: Node, callback?: NodeTranslatedCallback) { // Handle text nodes and attributes - const translateSingleNode = (node: Node) => { + const translate = (node: Node) => { if (this.filter && !this.filter(node)) return; // translate later if possible - if (this.nodeIntersectionObserver) { + if (this.nodesIntersectionObserver) { // Check that the node is attached to the DOM. This means the node is accessible by traversing the current DOM // This check is necessary to avoid lazy translation for nodes that are detached from the DOM, // since they potentially may never intersect with the viewport const isAttachedToDOM = node.getRootNode() !== node; if (isAttachedToDOM) { - this.nodeIntersectionObserver.observe(node, () => { + this.nodesIntersectionObserver.observe(node, () => { this.nodesTranslator.translateNode(node, callback); }); return; @@ -63,23 +67,23 @@ export class TranslationDispatcher { if (node instanceof Element) { visitWholeTree(node, (node) => { if (node instanceof Element) return; - translateSingleNode(node); + translate(node); }); } else { - translateSingleNode(node); + translate(node); } } /** - * Restores the original node text. - * For elements, restores each child node (Text, Attr, etc.) + * Restores the original node text. For elements, restores each child node (Text, Attr, etc.) * - * @param callback - Optional. Called synchronously after each individual node is restored + * @param [callback] - Called synchronously after each individual node is restored. + * The callback received restored node */ public restoreNode(node: Node, callback?: (node: Node) => void) { - const restoreSingleNode = (node: Node) => { - if (this.nodeIntersectionObserver) { - this.nodeIntersectionObserver.unobserve(node); + const restore = (node: Node) => { + if (this.nodesIntersectionObserver) { + this.nodesIntersectionObserver.unobserve(node); } this.nodesTranslator.restoreNode(node); @@ -88,16 +92,17 @@ export class TranslationDispatcher { // restore all nested nodes if (node instanceof Element) { - visitWholeTree(node, restoreSingleNode); + visitWholeTree(node, restore); } else { - restoreSingleNode(node); + restore(node); } } /** * Re-translates a node after it has been modified. * - * @param callback - Optional. Called after the node has been re-translated. + * @param [callback] - Called after the node has been re-translated. + * The callback receives the translated node */ public updateNode(node: Node, callback?: NodeTranslatedCallback) { this.nodesTranslator.updateNode(node, callback); diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index a438ce5..f57088c 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -22,7 +22,7 @@ test('In lazy-translation mode a non-intersecting node translates immediately', const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodesTranslator: new DOMNodesTranslator(translator), - nodeIntersectionObserver: new NodesIntersectionObserver(), + nodesIntersectionObserver: new NodesIntersectionObserver(), }); // OPTION node is not intersectable; it cannot be translated later @@ -48,7 +48,7 @@ test('In lazy-translation mode a node not attached to the body translates immedi const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodesTranslator: domNodesTranslator, - nodeIntersectionObserver: new NodesIntersectionObserver(), + nodesIntersectionObserver: new NodesIntersectionObserver(), }); // the node is outside the document.body, it is not intersecteble and cannot be translated later From 035f23e323b4a3c0b797ccf338b9594ff9dc1d2b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 11 Jun 2025 18:44:35 +0200 Subject: [PATCH 248/313] chore: improve docs, fix typo, simplify code --- src/NodesTranslator.ts | 15 ++++++++------- src/__tests__/NodesTranslator.test.ts | 4 ++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index e86b691..e1a9009 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -15,16 +15,18 @@ export class NodesTranslator { constructor({ dispatcher, - nodesTranslator: nodeTranslator, + nodesTranslator, }: { dispatcher: TranslationDispatcher; nodesTranslator: DOMNodesTranslator; }) { this.dispatcher = dispatcher; - this.nodesTranslator = nodeTranslator; + this.nodesTranslator = nodesTranslator; } - private mutatedNodes = new WeakSet(); + // Stores nodes mutated as a result of translation + // used to prevent handling mutation events triggered by our own translations + private readonly mutatedNodes = new WeakSet(); private readonly observedNodesStorage = new Map(); public observe(node: Element) { @@ -55,11 +57,9 @@ export class NodesTranslator { ); }); observer.addHandler('changeAttribute', ({ target, attributeName }) => { - if (attributeName === undefined || attributeName === null) return; - if (!(target instanceof Element)) return; + if (!attributeName || !(target instanceof Element)) return; const attribute = target.attributes.getNamedItem(attributeName); - if (attribute === null) return; // skip this update if it was triggered by the translation itself @@ -89,7 +89,8 @@ export class NodesTranslator { throw new Error('Node is not under observe'); } - // restore the node and all nested nodes, and remove them from mutatedNodes + // mutatedNodes may include nodes from multiple observed trees — remove only those belonging to the unobserved node + // restoreNode calls the callback after restoring each node; the callback removes that node from mutatedNodes this.dispatcher.restoreNode(node, (node) => { this.mutatedNodes.delete(node); }); diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 6500cf1..e0e8176 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -41,14 +41,14 @@ function buildTranslationServices( const domNodesTranslator = new DOMNodesTranslator(translateCallback); - const nodeIntersectionObserver = config.lazyTranslate + const nodesIntersectionObserver = config.lazyTranslate ? new NodesIntersectionObserver() : undefined; const translatorDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodesTranslator: domNodesTranslator, - nodeIntersectionObserver, + nodesIntersectionObserver, }); return { From 51f997aec97367bff33278db3953be340ac66b45 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 11 Jun 2025 19:05:21 +0200 Subject: [PATCH 249/313] chore: fix typo --- src/DOMNodesTranslator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index aafbc14..76abddd 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -72,7 +72,7 @@ export class DOMNodesTranslator { /** * Translates nodes that contain text (e.g., Text, Attr) - * After translation invokes a callback with the translated node + * After translation calls the callback with the translated node */ public translateNode = (node: Node, callback?: NodeTranslatedCallback) => { if (this.hasNode(node)) return; @@ -106,7 +106,7 @@ export class DOMNodesTranslator { /** * Translates node after it has been modified - * After translation invokes a callback with the translated node + * After translation calls the callback with the translated node */ public updateNode(node: Node, callback?: NodeTranslatedCallback) { const nodeData = this.nodeStorage.get(node); From d5fd31ce2ef3dd2009ff7a7bc71bbc2e0089827e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 11 Jun 2025 19:31:03 +0200 Subject: [PATCH 250/313] chore: fix typo --- src/TranslationDispatcher.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 3bef9b3..1a2dabc 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -24,7 +24,7 @@ export class TranslationDispatcher { */ filter?: TranslatableNodePredicate; /** - * If nodesIntersectionObserver is provided, nodes can be translated delayed - after intersect the viewport + * If is provided, nodes can be translated delayed - after intersect the viewport */ nodesIntersectionObserver?: NodesIntersectionObserver; }) { @@ -36,8 +36,7 @@ export class TranslationDispatcher { /** * Translates the node and all its nested translatable nodes (Text, Attr, etc.) * - * @param [callback] - Called asynchronously for each translated node, in the same order as nodes are translated. - * The callback receives the translated node + * @param [callback] - Called asynchronously with each translated node, in the order in which the nodes are translated */ public translateNode(node: Node, callback?: NodeTranslatedCallback) { // Handle text nodes and attributes @@ -77,8 +76,7 @@ export class TranslationDispatcher { /** * Restores the original node text. For elements, restores each child node (Text, Attr, etc.) * - * @param [callback] - Called synchronously after each individual node is restored. - * The callback received restored node + * @param [callback] - Called synchronously after each node is restored, receiving the restored node */ public restoreNode(node: Node, callback?: (node: Node) => void) { const restore = (node: Node) => { @@ -101,8 +99,7 @@ export class TranslationDispatcher { /** * Re-translates a node after it has been modified. * - * @param [callback] - Called after the node has been re-translated. - * The callback receives the translated node + * @param [callback] - Called with the translated node after it has been re-translated */ public updateNode(node: Node, callback?: NodeTranslatedCallback) { this.nodesTranslator.updateNode(node, callback); From 1586272ffe68d4596111dd5431536f5e729a72df Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 11 Jun 2025 21:56:25 +0200 Subject: [PATCH 251/313] test: improve code --- src/__tests__/DOMNodesTranslator.test.ts | 25 +++++----- .../NodesTranslator.preventRecursion.test.ts | 46 +++++++++---------- src/__tests__/TranslationDispatcher.test.ts | 30 ++++++------ 3 files changed, 49 insertions(+), 52 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 92ceda3..b0f7266 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -7,7 +7,7 @@ import { translator, } from './utils'; -test('Translates a node and restores the original node text', async () => { +test('Translates and restores a node and restores the original node text', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const text = 'Hello world!'; const div = document.createElement('div'); @@ -63,29 +63,31 @@ test('Stores the node after translation and removes it after restoration', async test('UpdateNode method translates the modified node', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); - const node = document.createElement('a'); + const div = document.createElement('div'); const text1 = 'title text'; - node.setAttribute('title', text1); + div.setAttribute('title', text1); // translate - domNodesTranslator.translateNode(node.attributes[0]); + domNodesTranslator.translateNode(div.attributes[0]); await awaitTranslation(); - expect(node.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); // update value const text2 = 'title text is update'; - node.setAttribute('title', text2); + div.setAttribute('title', text2); - domNodesTranslator.updateNode(node.attributes[0]); + domNodesTranslator.updateNode(div.attributes[0]); await awaitTranslation(); - expect(node.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(node.getAttribute('title')).toMatch(text2); + expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.getAttribute('title')).toMatch(text2); - domNodesTranslator.restoreNode(node.attributes[0]); - expect(node.getAttribute('title')).toBe(text2); + domNodesTranslator.restoreNode(div.attributes[0]); + expect(div.getAttribute('title')).toBe(text2); }); test('Callback is called only after successful translation', async () => { + const callback = vi.fn(); + // first translation call resolves after 300 ms, second — after 100 ms const translatorWithDelay = vi .fn() @@ -97,7 +99,6 @@ test('Callback is called only after successful translation', async () => { (text: string) => new Promise((res) => setTimeout(() => res(translator(text)), 100)), ); - const callback = vi.fn(); const domNodesTranslator = new DOMNodesTranslator(translatorWithDelay); const div = document.createElement('div'); diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index 54367cc..0a89c03 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -27,37 +27,37 @@ function buildTranslationServices(translator: TranslatorInterface) { const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); - return { dispatcher, nodesTranslator, updateNodeSpy }; + return { nodesTranslator, updateNodeSpy }; } test('Translation of nodes does not trigger recursive updateNode calls', async () => { const { nodesTranslator, updateNodeSpy } = buildTranslationServices(translator); - const div = document.createElement('div'); - div.textContent = 'Simple text'; - document.body.appendChild(div); - nodesTranslator.observe(div); + const div1 = document.createElement('div'); + div1.textContent = 'Simple text'; + document.body.appendChild(div1); + nodesTranslator.observe(div1); await awaitTranslation(); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div1.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); await awaitTranslation(); expect(updateNodeSpy.mock.calls).toEqual([]); // add new element - const div1 = document.createElement('div'); - div1.textContent = 'new text'; - div.appendChild(div1); + const div2 = document.createElement('div'); + div2.textContent = 'New text'; + div1.appendChild(div2); await awaitTranslation(); - expect(div1.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div2.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); await awaitTranslation(); expect(updateNodeSpy.mock.calls).toEqual([]); // add new attribute - div.setAttribute('title', 'Short text'); + div1.setAttribute('title', 'Short text'); await awaitTranslation(); - expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div1.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); await awaitTranslation(); expect(updateNodeSpy.mock.calls).toEqual([]); }); @@ -65,11 +65,11 @@ test('Translation of nodes does not trigger recursive updateNode calls', async ( test('Updating a node does not trigger recursive updateNode calls', async () => { const { nodesTranslator, updateNodeSpy } = buildTranslationServices(translator); - const div = document.createElement('a'); + const div = document.createElement('div'); div.setAttribute('title', 'title text'); document.body.appendChild(div); - nodesTranslator.observe(div); + nodesTranslator.observe(div); await awaitTranslation(); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); await awaitTranslation(); @@ -91,7 +91,7 @@ test('Updating a node does not trigger recursive updateNode calls', async () => test('Updating a node with a translated-looking value not trigger recursive updateNode calls', async () => { const { nodesTranslator, updateNodeSpy } = buildTranslationServices(translator); - const div = document.createElement('a'); + const div = document.createElement('div'); const text1 = 'title text'; div.setAttribute('title', text1); document.body.appendChild(div); @@ -124,19 +124,15 @@ test('Only the latest translation will be applied to the node', async () => { .fn() .mockImplementationOnce( (text: string) => - new Promise((resolve) => - setTimeout(() => resolve(translator(text)), 300), - ), + new Promise((res) => setTimeout(() => res(translator(text)), 300)), ) .mockImplementationOnce( (text: string) => - new Promise((resolve) => - setTimeout(() => resolve(translator(text)), 100), - ), + new Promise((res) => setTimeout(() => res(translator(text)), 100)), ); const { nodesTranslator } = buildTranslationServices(translatorWithDelay); - const div = document.createElement('a'); + const div = document.createElement('div'); const text1 = 'title text'; div.setAttribute('title', text1); document.body.appendChild(div); @@ -144,7 +140,7 @@ test('Only the latest translation will be applied to the node', async () => { // first slow translation (300ms) nodesTranslator.observe(div); - // waiting 100ms: the translation is not completed yet, callback should not be called + // waiting 100ms: the translation is not completed yet, node not changed await delay(100); await awaitTranslation(); expect(translatorWithDelay).toHaveBeenCalledTimes(1); @@ -154,14 +150,14 @@ test('Only the latest translation will be applied to the node', async () => { const text2 = 'new title text'; div.setAttribute('title', text2); - // waiting 100 ms: the translation is complete and the callback should be called + // waiting 100 ms: the translation is complete and node was changed await delay(100); await awaitTranslation(); expect(translatorWithDelay).toHaveBeenCalledTimes(2); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(div.getAttribute('title')).toMatch(text2); - // wait for the first translation to finish. Callback should not be called again + // wait for the first translation to finish await delay(200); await awaitTranslation(); expect(div.getAttribute('title')).toMatch(text2); diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index f57088c..4027283 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -54,7 +54,7 @@ test('In lazy-translation mode a node not attached to the body translates immedi // the node is outside the document.body, it is not intersecteble and cannot be translated later const head = document.createElement('head'); const title = document.createElement('title'); - title.textContent = 'Title can contain only text'; + title.textContent = 'Title text'; head.appendChild(title); translationDispatcher.translateNode(head); @@ -63,29 +63,29 @@ test('In lazy-translation mode a node not attached to the body translates immedi }); test('Translates and restores the element and its child elements', async () => { - const div = document.createElement('div'); - const text1 = 'Would you like a cup of tea?'; - div.textContent = text1; const div1 = document.createElement('div'); + const text1 = 'Would you like a cup of tea?'; + div1.textContent = text1; + const div2 = document.createElement('div'); const text2 = 'Hi! yes i would'; - div1.textContent = text2; - div.appendChild(div1); - document.body.appendChild(div); + div2.textContent = text2; + div1.appendChild(div2); + document.body.appendChild(div1); const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodesTranslator: new DOMNodesTranslator(translator), }); - translationDispatcher.translateNode(div); + translationDispatcher.translateNode(div1); await awaitTranslation(); // check the text on the element itself - expect(div.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(div1.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div2.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - translationDispatcher.restoreNode(div); - expect(div.childNodes[0].textContent).toBe(text1); - expect(div1.childNodes[0].textContent).toBe(text2); + translationDispatcher.restoreNode(div1); + expect(div1.childNodes[0].textContent).toBe(text1); + expect(div2.childNodes[0].textContent).toBe(text2); }); test('Does not translate ignored node', async () => { @@ -98,10 +98,10 @@ test('Does not translate ignored node', async () => { }); const div = document.createElement('div'); - div.textContent = 'I`m block i have four corners'; - const comment = document.createComment('I`m comment node, not translate me please'); + div.textContent = 'I`m container'; + const comment = document.createComment('I`m comment node'); const p = document.createElement('p'); - p.textContent = 'I have text, i would be translated'; + p.textContent = 'I have text'; div.appendChild(p); div.appendChild(comment); document.body.appendChild(div); From ef316992108719a90d41c651e7267d44ad647f09 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 11 Jun 2025 22:00:47 +0200 Subject: [PATCH 252/313] chore: fix typo --- src/DOMNodesTranslator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 76abddd..278c691 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -97,7 +97,6 @@ export class DOMNodesTranslator { const nodeData = this.nodeStorage.get(node); if (!nodeData) return; - // Restore original text if text been replaced if (nodeData.originalText !== null) { node.nodeValue = nodeData.originalText; } @@ -138,6 +137,7 @@ export class DOMNodesTranslator { return; } + console.log('translate text', text); actualNodeData.originalText = node.nodeValue !== null ? node.nodeValue : ''; node.nodeValue = text; From 9168feb146df26af002cb24d9ba2e908200883e4 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 14 Jun 2025 00:36:13 +0200 Subject: [PATCH 253/313] chore: fix docs --- src/NodesTranslator.ts | 2 +- src/TranslationDispatcher.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index e1a9009..0a5173e 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -89,7 +89,7 @@ export class NodesTranslator { throw new Error('Node is not under observe'); } - // mutatedNodes may include nodes from multiple observed trees — remove only those belonging to the unobserved node + // mutatedNodes may include nodes from multiple observed tree elements — remove only those belonging to the unobserved // restoreNode calls the callback after restoring each node; the callback removes that node from mutatedNodes this.dispatcher.restoreNode(node, (node) => { this.mutatedNodes.delete(node); diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 1a2dabc..bbda73d 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -36,7 +36,8 @@ export class TranslationDispatcher { /** * Translates the node and all its nested translatable nodes (Text, Attr, etc.) * - * @param [callback] - Called asynchronously with each translated node, in the order in which the nodes are translated + * @param callback - Called asynchronously for each translated node, in the order of translation. + * The callback receives the translated node as argument. */ public translateNode(node: Node, callback?: NodeTranslatedCallback) { // Handle text nodes and attributes @@ -76,7 +77,7 @@ export class TranslationDispatcher { /** * Restores the original node text. For elements, restores each child node (Text, Attr, etc.) * - * @param [callback] - Called synchronously after each node is restored, receiving the restored node + * @param callback Called synchronously after each node is restored, receiving the restored node */ public restoreNode(node: Node, callback?: (node: Node) => void) { const restore = (node: Node) => { @@ -99,7 +100,7 @@ export class TranslationDispatcher { /** * Re-translates a node after it has been modified. * - * @param [callback] - Called with the translated node after it has been re-translated + * @param callback Called asynchronously with the translated node once the update is complete */ public updateNode(node: Node, callback?: NodeTranslatedCallback) { this.nodesTranslator.updateNode(node, callback); From b90fc0ba61b71c388ce1d8592b1be998da214493 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 14 Jun 2025 12:55:13 +0200 Subject: [PATCH 254/313] chore: remove callback --- src/DOMNodesTranslator.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 278c691..9b36f16 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -137,7 +137,6 @@ export class DOMNodesTranslator { return; } - console.log('translate text', text); actualNodeData.originalText = node.nodeValue !== null ? node.nodeValue : ''; node.nodeValue = text; From 9be7a63247641d68cc54ff1f01b1b503c3e7788c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 14 Jun 2025 13:41:37 +0200 Subject: [PATCH 255/313] refactor: use in callback received node --- src/TranslationDispatcher.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index bbda73d..202c2a5 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -36,7 +36,7 @@ export class TranslationDispatcher { /** * Translates the node and all its nested translatable nodes (Text, Attr, etc.) * - * @param callback - Called asynchronously for each translated node, in the order of translation. + * @param callback - Called asynchronously after each node is translated, in the order of translation. * The callback receives the translated node as argument. */ public translateNode(node: Node, callback?: NodeTranslatedCallback) { @@ -52,7 +52,7 @@ export class TranslationDispatcher { const isAttachedToDOM = node.getRootNode() !== node; if (isAttachedToDOM) { - this.nodesIntersectionObserver.observe(node, () => { + this.nodesIntersectionObserver.observe(node, (node) => { this.nodesTranslator.translateNode(node, callback); }); return; @@ -77,7 +77,7 @@ export class TranslationDispatcher { /** * Restores the original node text. For elements, restores each child node (Text, Attr, etc.) * - * @param callback Called synchronously after each node is restored, receiving the restored node + * @param callback - Called synchronously after each node is restored, receiving the restored node */ public restoreNode(node: Node, callback?: (node: Node) => void) { const restore = (node: Node) => { @@ -100,7 +100,7 @@ export class TranslationDispatcher { /** * Re-translates a node after it has been modified. * - * @param callback Called asynchronously with the translated node once the update is complete + * @param callback - Called asynchronously with the translated node once the update is complete */ public updateNode(node: Node, callback?: NodeTranslatedCallback) { this.nodesTranslator.updateNode(node, callback); From fcd547be297cdea981af57a607b81a0eea872d9c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 14 Jun 2025 17:18:47 +0200 Subject: [PATCH 256/313] chore: improve docs --- src/lib/NodesIntersectionObserver.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.ts b/src/lib/NodesIntersectionObserver.ts index bb065db..34a1c83 100644 --- a/src/lib/NodesIntersectionObserver.ts +++ b/src/lib/NodesIntersectionObserver.ts @@ -1,8 +1,12 @@ import { isIntersectableNode } from '../utils/isIntersectableNode'; +type Callback = (node: Node) => void; + /** - * @returns Returns the node owner element. - * For Element, the element itself is returned + * Returns the node owner element: + * - For Element returns itself + * - For Attr returns owner ownerElement + * - For Text and other node returns parentElement */ export function getElementOfNode(node: Node) { // Use type guards because a simple check `node.nodeType === Node.ELEMENT_NODE` @@ -25,8 +29,6 @@ export function getElementOfNode(node: Node) { return node.parentElement; } -type Callback = (node: Node) => void; - /** * Observes DOM nodes for intersection with the viewport and triggers callbacks when they become visible. * Class supports observing both elements and nodes (Text, Attr, etc.) @@ -59,6 +61,7 @@ export class NodesIntersectionObserver { * Starts observing the node for intersection. * When the owner element of the node intersects the viewport, the callback is invoked. * Then the owner element and all its tracked nodes are automatically removed from observation. + * * (Owner element means: element itself for Element, parent element for Text, owner element for Attr) */ public observe(node: Node, callback: Callback) { From 34d2cacf57e422ea1aee3f008c4dda2d38e47023 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 14 Jun 2025 17:41:44 +0200 Subject: [PATCH 257/313] test: fix typo, improve comments --- src/__tests__/DOMNodesTranslator.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index b0f7266..6c3958e 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -7,7 +7,7 @@ import { translator, } from './utils'; -test('Translates and restores a node and restores the original node text', async () => { +test('Translates a node and restores the original node text', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const text = 'Hello world!'; const div = document.createElement('div'); @@ -21,7 +21,7 @@ test('Translates and restores a node and restores the original node text', async expect(div.textContent).toBe(text); }); -test('Stores original text on translation and clears it after restoration', async () => { +test('Stores original node text on translation and clears it after restoration', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const text = 'Hello world!'; const div = document.createElement('div'); @@ -44,8 +44,8 @@ test('Stores original text on translation and clears it after restoration', asyn test('Stores the node after translation and removes it after restoration', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); - const div = document.createElement('div'); const text = 'Hello world!'; + const div = document.createElement('div'); div.textContent = text; // not exists before translate @@ -78,6 +78,8 @@ test('UpdateNode method translates the modified node', async () => { domNodesTranslator.updateNode(div.attributes[0]); await awaitTranslation(); + + // check that the node value is the translated new value expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(div.getAttribute('title')).toMatch(text2); @@ -108,7 +110,7 @@ test('Callback is called only after successful translation', async () => { // first slow translation (300ms) domNodesTranslator.translateNode(div.attributes[0], callback); - // waiting 100ms: the translation is not completed yet, callback should not be called + // waiting (less then 300 ms); the translation is not completed yet, callback should not be called await delay(100); await awaitTranslation(); expect(callback).toBeCalledTimes(0); @@ -118,7 +120,7 @@ test('Callback is called only after successful translation', async () => { div.setAttribute('title', 'Hi friends!'); domNodesTranslator.updateNode(div.attributes[0], callback); - // waiting 100 ms: the translation is complete and the callback should be called + // waiting (more then 100 ms), the translation is complete and the callback should be called await delay(100); await awaitTranslation(); expect(callback).toBeCalledTimes(1); From 3e94e1a3cd652c8ed0f74131df0a0f5b3dc1ca5f Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 14 Jun 2025 18:53:35 +0200 Subject: [PATCH 258/313] refactor: filter node before call restore --- src/TranslationDispatcher.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/TranslationDispatcher.ts b/src/TranslationDispatcher.ts index 202c2a5..d921730 100644 --- a/src/TranslationDispatcher.ts +++ b/src/TranslationDispatcher.ts @@ -91,7 +91,10 @@ export class TranslationDispatcher { // restore all nested nodes if (node instanceof Element) { - visitWholeTree(node, restore); + visitWholeTree(node, (node) => { + if (node instanceof Element) return; + restore(node); + }); } else { restore(node); } From 8d472af925765916e8be0859fd6ffcb1046476d3 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 14 Jun 2025 19:08:55 +0200 Subject: [PATCH 259/313] test: add test, improve --- src/__tests__/TranslationDispatcher.test.ts | 53 +++++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 4027283..7bc0055 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -44,10 +44,9 @@ test('In lazy-translation mode a non-intersecting node translates immediately', }); test('In lazy-translation mode a node not attached to the body translates immediately', async () => { - const domNodesTranslator = new DOMNodesTranslator(translator); const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, - nodesTranslator: domNodesTranslator, + nodesTranslator: new DOMNodesTranslator(translator), nodesIntersectionObserver: new NodesIntersectionObserver(), }); @@ -63,6 +62,12 @@ test('In lazy-translation mode a node not attached to the body translates immedi }); test('Translates and restores the element and its child elements', async () => { + const translationDispatcher = new TranslationDispatcher({ + filter: isTranslatableNode, + nodesTranslator: new DOMNodesTranslator(translator), + nodesIntersectionObserver: new NodesIntersectionObserver(), + }); + const div1 = document.createElement('div'); const text1 = 'Would you like a cup of tea?'; div1.textContent = text1; @@ -72,11 +77,6 @@ test('Translates and restores the element and its child elements', async () => { div1.appendChild(div2); document.body.appendChild(div1); - const translationDispatcher = new TranslationDispatcher({ - filter: isTranslatableNode, - nodesTranslator: new DOMNodesTranslator(translator), - }); - translationDispatcher.translateNode(div1); await awaitTranslation(); // check the text on the element itself @@ -88,6 +88,30 @@ test('Translates and restores the element and its child elements', async () => { expect(div2.childNodes[0].textContent).toBe(text2); }); +test('Calls callback after restore node', async () => { + const callback = vi.fn(); + const translationDispatcher = new TranslationDispatcher({ + filter: isTranslatableNode, + nodesTranslator: new DOMNodesTranslator(translator), + nodesIntersectionObserver: new NodesIntersectionObserver(), + }); + + const div = document.createElement('div'); + const text = 'Hello world'; + div.textContent = text; + document.body.appendChild(div); + + // translate + translationDispatcher.translateNode(div); + await awaitTranslation(); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + + // restore + translationDispatcher.restoreNode(div, callback); + expect(div.textContent).toBe(text); + expect(callback.mock.calls[0][0]).toEqual(div.childNodes[0]); +}); + test('Does not translate ignored node', async () => { const filter = configureTranslatableNodePredicate({ ignoredSelectors: ['comment'], @@ -95,22 +119,21 @@ test('Does not translate ignored node', async () => { const translationDispatcher = new TranslationDispatcher({ filter, nodesTranslator: new DOMNodesTranslator(translator), + nodesIntersectionObserver: new NodesIntersectionObserver(), }); const div = document.createElement('div'); - div.textContent = 'I`m container'; - const comment = document.createComment('I`m comment node'); - const p = document.createElement('p'); - p.textContent = 'I have text'; - div.appendChild(p); + div.textContent = 'I`m container node'; + const text = 'I`m comment node'; + const comment = document.createComment(text); div.appendChild(comment); document.body.appendChild(div); translationDispatcher.translateNode(div); await awaitTranslation(); - expect(div.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(p.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + // comment not translated - expect(comment.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(comment.textContent).toBe(text); }); From f5f0e6d97c84ecffebc51baf049df78961d9197e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 14 Jun 2025 19:13:12 +0200 Subject: [PATCH 260/313] test: add comment, imptove check --- src/__tests__/TranslationDispatcher.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 7bc0055..b56d337 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -79,7 +79,8 @@ test('Translates and restores the element and its child elements', async () => { translationDispatcher.translateNode(div1); await awaitTranslation(); - // check the text on the element itself + + // check the text content of the element itself, because div1.textContent includes the text of child nodes expect(div1.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); expect(div2.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); @@ -109,7 +110,7 @@ test('Calls callback after restore node', async () => { // restore translationDispatcher.restoreNode(div, callback); expect(div.textContent).toBe(text); - expect(callback.mock.calls[0][0]).toEqual(div.childNodes[0]); + expect(callback.mock.calls).toEqual([[div.childNodes[0]]]); }); test('Does not translate ignored node', async () => { From 36db7525167d0c4d5d2636b010fb80e36ab3fb87 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 14 Jun 2025 19:40:49 +0200 Subject: [PATCH 261/313] test: improve --- .../NodesTranslator.preventRecursion.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index 0a89c03..c1414a4 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -79,11 +79,11 @@ test('Updating a node does not trigger recursive updateNode calls', async () => const text = 'new text'; div.setAttribute('title', text); await awaitTranslation(); - expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.attributes[0]); + + // second arg is a callback, not relevant for this test + expect(updateNodeSpy.mock.calls).toEqual([[div.attributes[0], expect.any(Function)]]); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(div.getAttribute('title')).toMatch(text); - // no recursion await awaitTranslation(); expect(updateNodeSpy).toBeCalledTimes(1); }); @@ -106,10 +106,11 @@ test('Updating a node with a translated-looking value not trigger recursive upda const text2 = TRANSLATION_SYMBOL + text1; div.setAttribute('title', text2); await awaitTranslation(); - expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.attributes[0]); + + // second arg is a callback, not relevant for this test + expect(updateNodeSpy.mock.calls).toEqual([[div.attributes[0], expect.any(Function)]]); expect(div.getAttribute('title')).toBe(TRANSLATION_SYMBOL + text2); - // no recursion await awaitTranslation(); expect(updateNodeSpy).toHaveBeenCalledTimes(1); @@ -140,7 +141,7 @@ test('Only the latest translation will be applied to the node', async () => { // first slow translation (300ms) nodesTranslator.observe(div); - // waiting 100ms: the translation is not completed yet, node not changed + // waiting (less then 300 ms); the translation is not completed yet, node not changed await delay(100); await awaitTranslation(); expect(translatorWithDelay).toHaveBeenCalledTimes(1); @@ -150,14 +151,13 @@ test('Only the latest translation will be applied to the node', async () => { const text2 = 'new title text'; div.setAttribute('title', text2); - // waiting 100 ms: the translation is complete and node was changed + // waiting (more then 100 ms); the translation is complete and node was changed await delay(100); await awaitTranslation(); expect(translatorWithDelay).toHaveBeenCalledTimes(2); expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(div.getAttribute('title')).toMatch(text2); - // wait for the first translation to finish + // wait for first translation to finish; translation not applied, node remains unchanged await delay(200); await awaitTranslation(); expect(div.getAttribute('title')).toMatch(text2); From a684a6d0382cf4bc90ea3bdb21425d4cccc92ccf Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 17 Jun 2025 18:46:27 +0200 Subject: [PATCH 262/313] test: improve name and test case --- src/__tests__/DOMNodesTranslator.test.ts | 2 +- src/__tests__/TranslationDispatcher.test.ts | 29 ++++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 6c3958e..6f5e8af 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -87,7 +87,7 @@ test('UpdateNode method translates the modified node', async () => { expect(div.getAttribute('title')).toBe(text2); }); -test('Callback is called only after successful translation', async () => { +test('Callback is called only once after latest completed translation', async () => { const callback = vi.fn(); // first translation call resolves after 300 ms, second — after 100 ms diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index b56d337..208b26f 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -14,6 +14,8 @@ require('intersection-observer'); beforeEach(() => { document.body.innerHTML = ''; + mockBoundingClientRect(document.body, { width: 0, height: 0, x: 0, y: 0 }); + vi.clearAllMocks(); }); const isTranslatableNode = () => true; @@ -43,22 +45,31 @@ test('In lazy-translation mode a non-intersecting node translates immediately', expect(option.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); -test('In lazy-translation mode a node not attached to the body translates immediately', async () => { +test('In lazy-translation mode node not attached to document.body translate immediately', async () => { const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodesTranslator: new DOMNodesTranslator(translator), nodesIntersectionObserver: new NodesIntersectionObserver(), }); - // the node is outside the document.body, it is not intersecteble and cannot be translated later - const head = document.createElement('head'); - const title = document.createElement('title'); - title.textContent = 'Title text'; - head.appendChild(title); + // shadow element is not attached to document.body, so it is not intersectable and cannot be translated later + const host = document.createElement('div'); + const shadowRoot = host.attachShadow({ mode: 'open' }); + const div = document.createElement('div'); + const text = 'Hello world'; + div.textContent = text; + + shadowRoot.appendChild(div); + document.body.appendChild(host); + + // element is outside the viewport + // IntersectionObserver should not invoke the callback until the node appears in the viewport + mockBoundingClientRect(div, { width: 50, height: 100, x: 0, y: 300 }); + mockBoundingClientRect(document.body, { width: 100, height: 200, x: 0, y: 0 }); - translationDispatcher.translateNode(head); + translationDispatcher.translateNode(div); await awaitTranslation(); - expect(title.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); }); test('Translates and restores the element and its child elements', async () => { @@ -89,7 +100,7 @@ test('Translates and restores the element and its child elements', async () => { expect(div2.childNodes[0].textContent).toBe(text2); }); -test('Calls callback after restore node', async () => { +test('Callback is called after the node is restored', async () => { const callback = vi.fn(); const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, From 85d4fab29e032f7f838beb6cdcdba7c85f3c7aa6 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 19 Jun 2025 19:09:53 +0200 Subject: [PATCH 263/313] refactor: trigger callback after delete --- src/lib/NodesIntersectionObserver.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.ts b/src/lib/NodesIntersectionObserver.ts index 34a1c83..a706888 100644 --- a/src/lib/NodesIntersectionObserver.ts +++ b/src/lib/NodesIntersectionObserver.ts @@ -109,13 +109,13 @@ export class NodesIntersectionObserver { */ private triggerNestedNodes(node: Element) { const ownedNodes = this.elementNodesMap.get(node); - if (ownedNodes) { - ownedNodes.forEach((node) => { - const callback = this.nodeCallbacksMap.get(node); - if (callback) callback(node); + if (!ownedNodes) return; - this.nodeCallbacksMap.delete(node); - }); - } + ownedNodes.forEach((node) => { + const callback = this.nodeCallbacksMap.get(node); + this.nodeCallbacksMap.delete(node); + + if (callback) callback(node); + }); } } From 930d50b002f3c33e06e7da2ec86d277a6b20b187 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 19 Jun 2025 19:11:32 +0200 Subject: [PATCH 264/313] chore: move function --- src/lib/NodesIntersectionObserver.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.ts b/src/lib/NodesIntersectionObserver.ts index a706888..b70fd24 100644 --- a/src/lib/NodesIntersectionObserver.ts +++ b/src/lib/NodesIntersectionObserver.ts @@ -2,6 +2,16 @@ import { isIntersectableNode } from '../utils/isIntersectableNode'; type Callback = (node: Node) => void; +// Use type guards because a simple check `node.nodeType === Node.ELEMENT_NODE` +// does not narrow the type in TypeScript — `node` remains of type `Node` + +const isElement = (node: Node): node is Element => { + return node.nodeType === Node.ELEMENT_NODE; +}; +const isAttr = (node: Node): node is Attr => { + return node.nodeType === Node.ATTRIBUTE_NODE; +}; + /** * Returns the node owner element: * - For Element returns itself @@ -9,16 +19,6 @@ type Callback = (node: Node) => void; * - For Text and other node returns parentElement */ export function getElementOfNode(node: Node) { - // Use type guards because a simple check `node.nodeType === Node.ELEMENT_NODE` - // does not narrow the type in TypeScript — `node` remains of type `Node` - - const isElement = (node: Node): node is Element => { - return node.nodeType === Node.ELEMENT_NODE; - }; - const isAttr = (node: Node): node is Attr => { - return node.nodeType === Node.ATTRIBUTE_NODE; - }; - if (isElement(node)) { return node; } From eb21a3d6fa33525d72092bd1c58553a66989c5b3 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 19 Jun 2025 19:14:48 +0200 Subject: [PATCH 265/313] chore: move helpers to utils --- src/__tests__/NodesTranslator.test.ts | 20 ++++++++------------ src/__tests__/utils.ts | 4 ++++ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index e0e8176..11bdcc3 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -6,28 +6,24 @@ import { NodesIntersectionObserver } from '../lib/NodesIntersectionObserver'; import { NodesTranslator } from '../NodesTranslator'; import { TranslationDispatcher } from '../TranslationDispatcher'; import { configureTranslatableNodePredicate, NodesFilterOptions } from '../utils/nodes'; +import { + awaitTranslation, + containsRegex, + endsWithRegex, + startsWithRegex, + TRANSLATION_SYMBOL, + translator, +} from './utils'; require('intersection-observer'); (IntersectionObserver.prototype as any).POLL_INTERVAL = 100; -const delay = (time: number) => new Promise((res) => setTimeout(res, time)); -const awaitTranslation = () => delay(120); - const getElementText = (elm: Element | null) => elm && elm.textContent ? elm.textContent.trim() : null; const composeName = (...args: (string | boolean)[]) => args.filter(Boolean).join(' '); -const TRANSLATION_SYMBOL = '***TRANSLATED***'; -const translator = async (text: string) => TRANSLATION_SYMBOL + text; - -const escapeRegexString = (input: string) => input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - -const startsWithRegex = (input: string) => new RegExp(`^${escapeRegexString(input)}`); -const endsWithRegex = (input: string) => new RegExp(`${escapeRegexString(input)}$`); -const containsRegex = (input: string) => new RegExp(`${escapeRegexString(input)}`); - const fillDocument = (text: string) => { document.write(text); }; diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts index 2336a37..21d542a 100644 --- a/src/__tests__/utils.ts +++ b/src/__tests__/utils.ts @@ -7,6 +7,10 @@ export const translator = async (text: string) => TRANSLATION_SYMBOL + text; export const escapeRegexString = (input: string) => input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +export const startsWithRegex = (input: string) => + new RegExp(`^${escapeRegexString(input)}`); +export const endsWithRegex = (input: string) => + new RegExp(`${escapeRegexString(input)}$`); export const containsRegex = (input: string) => new RegExp(`${escapeRegexString(input)}`); /** From 7445d601fbe32a249dc3ad0ec247fe1e1827872e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 19 Jun 2025 19:23:54 +0200 Subject: [PATCH 266/313] test: improve node text checks --- src/__tests__/DOMNodesTranslator.test.ts | 14 ++++++------ .../NodesTranslator.preventRecursion.test.ts | 16 +++++++------- src/__tests__/TranslationDispatcher.test.ts | 16 +++++++------- src/lib/NodesIntersectionObserver.test.ts | 22 +++++++++---------- 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 6f5e8af..94d51a8 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -1,8 +1,8 @@ import { DOMNodesTranslator } from '../DOMNodesTranslator'; import { awaitTranslation, - containsRegex, delay, + startsWithRegex, TRANSLATION_SYMBOL, translator, } from './utils'; @@ -15,7 +15,7 @@ test('Translates a node and restores the original node text', async () => { domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); domNodesTranslator.restoreNode(div.childNodes[0]); expect(div.textContent).toBe(text); @@ -33,7 +33,7 @@ test('Stores original node text on translation and clears it after restoration', domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toBe(text); // after restore @@ -53,7 +53,7 @@ test('Stores the node after translation and removes it after restoration', async domNodesTranslator.translateNode(div.childNodes[0]); await awaitTranslation(); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(true); domNodesTranslator.restoreNode(div.childNodes[0]); @@ -70,7 +70,7 @@ test('UpdateNode method translates the modified node', async () => { // translate domNodesTranslator.translateNode(div.attributes[0]); await awaitTranslation(); - expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // update value const text2 = 'title text is update'; @@ -80,7 +80,7 @@ test('UpdateNode method translates the modified node', async () => { await awaitTranslation(); // check that the node value is the translated new value - expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); expect(div.getAttribute('title')).toMatch(text2); domNodesTranslator.restoreNode(div.attributes[0]); @@ -124,7 +124,7 @@ test('Callback is called only once after latest completed translation', async () await delay(100); await awaitTranslation(); expect(callback).toBeCalledTimes(1); - expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // wait for the first translation to finish. Callback should not be called again await delay(200); diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index c1414a4..19f54ce 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -2,8 +2,8 @@ import { DOMNodesTranslator } from '../DOMNodesTranslator'; import { TranslationDispatcher } from '../TranslationDispatcher'; import { awaitTranslation, - containsRegex, delay, + startsWithRegex, TRANSLATION_SYMBOL, translator, } from './utils'; @@ -39,7 +39,7 @@ test('Translation of nodes does not trigger recursive updateNode calls', async ( nodesTranslator.observe(div1); await awaitTranslation(); - expect(div1.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div1.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); await awaitTranslation(); expect(updateNodeSpy.mock.calls).toEqual([]); @@ -49,7 +49,7 @@ test('Translation of nodes does not trigger recursive updateNode calls', async ( div1.appendChild(div2); await awaitTranslation(); - expect(div2.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div2.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); await awaitTranslation(); expect(updateNodeSpy.mock.calls).toEqual([]); @@ -57,7 +57,7 @@ test('Translation of nodes does not trigger recursive updateNode calls', async ( div1.setAttribute('title', 'Short text'); await awaitTranslation(); - expect(div1.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div1.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); await awaitTranslation(); expect(updateNodeSpy.mock.calls).toEqual([]); }); @@ -71,7 +71,7 @@ test('Updating a node does not trigger recursive updateNode calls', async () => nodesTranslator.observe(div); await awaitTranslation(); - expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); await awaitTranslation(); expect(updateNodeSpy.mock.calls).toEqual([]); @@ -82,7 +82,7 @@ test('Updating a node does not trigger recursive updateNode calls', async () => // second arg is a callback, not relevant for this test expect(updateNodeSpy.mock.calls).toEqual([[div.attributes[0], expect.any(Function)]]); - expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); await awaitTranslation(); expect(updateNodeSpy).toBeCalledTimes(1); @@ -98,7 +98,7 @@ test('Updating a node with a translated-looking value not trigger recursive upda nodesTranslator.observe(div); await awaitTranslation(); - expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); await awaitTranslation(); expect(updateNodeSpy.mock.calls).toEqual([]); @@ -155,7 +155,7 @@ test('Only the latest translation will be applied to the node', async () => { await delay(100); await awaitTranslation(); expect(translatorWithDelay).toHaveBeenCalledTimes(2); - expect(div.getAttribute('title')).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // wait for first translation to finish; translation not applied, node remains unchanged await delay(200); diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 208b26f..cfc7bee 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -4,8 +4,8 @@ import { TranslationDispatcher } from '../TranslationDispatcher'; import { configureTranslatableNodePredicate } from '../utils/nodes'; import { awaitTranslation, - containsRegex, mockBoundingClientRect, + startsWithRegex, TRANSLATION_SYMBOL, translator, } from './utils'; @@ -42,7 +42,7 @@ test('In lazy-translation mode a non-intersecting node translates immediately', // the element is translated regardless of viewport intersection translationDispatcher.translateNode(select); await awaitTranslation(); - expect(option.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(option.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); }); test('In lazy-translation mode node not attached to document.body translate immediately', async () => { @@ -69,7 +69,7 @@ test('In lazy-translation mode node not attached to document.body translate imme translationDispatcher.translateNode(div); await awaitTranslation(); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); }); test('Translates and restores the element and its child elements', async () => { @@ -91,11 +91,11 @@ test('Translates and restores the element and its child elements', async () => { translationDispatcher.translateNode(div1); await awaitTranslation(); - // check the text content of the element itself, because div1.textContent includes the text of child nodes - expect(div1.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); - expect(div2.childNodes[0].textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div1.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(div2.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); translationDispatcher.restoreNode(div1); + // check the text content of the element itself, because div1.textContent includes the text of child nodes expect(div1.childNodes[0].textContent).toBe(text1); expect(div2.childNodes[0].textContent).toBe(text2); }); @@ -116,7 +116,7 @@ test('Callback is called after the node is restored', async () => { // translate translationDispatcher.translateNode(div); await awaitTranslation(); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // restore translationDispatcher.restoreNode(div, callback); @@ -144,7 +144,7 @@ test('Does not translate ignored node', async () => { translationDispatcher.translateNode(div); await awaitTranslation(); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // comment not translated expect(comment.textContent).toBe(text); diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index 23d8dcd..5961c24 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -1,7 +1,7 @@ import { awaitTranslation, - containsRegex, mockBoundingClientRect, + startsWithRegex, TRANSLATION_SYMBOL, } from '../__tests__/utils'; import { NodesIntersectionObserver } from './NodesIntersectionObserver'; @@ -9,7 +9,7 @@ import { NodesIntersectionObserver } from './NodesIntersectionObserver'; require('intersection-observer'); const translator = vi.fn().mockImplementation(async (node: Node) => { - node.textContent += TRANSLATION_SYMBOL; + node.textContent = TRANSLATION_SYMBOL + node.textContent; }); beforeEach(() => { @@ -35,7 +35,7 @@ test('Triggers callback for node in viewport', async () => { // The mock function was called once expect(translator.mock.calls).toEqual([[div.childNodes[0]]]); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); }); test('Triggers callback for a node only when it becomes intersectable', async () => { @@ -52,14 +52,14 @@ test('Triggers callback for a node only when it becomes intersectable', async () await awaitTranslation(); expect(translator.mock.calls).toEqual([]); - expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // the node becomes visible and is translated div.style.display = 'block'; await awaitTranslation(); expect(translator.mock.calls).toEqual([[div.childNodes[0]]]); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); }); test('Does not trigger callback after node is detached', async () => { @@ -76,7 +76,7 @@ test('Does not trigger callback after node is detached', async () => { // does not translate because node is not visible expect(translator.mock.calls).toEqual([]); - expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // node is detached lazyTranslator.unobserve(div.childNodes[0]); @@ -85,7 +85,7 @@ test('Does not trigger callback after node is detached', async () => { div.style.display = 'block'; await awaitTranslation(); expect(translator.mock.calls).toEqual([]); - expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); }); test('Triggers callback only after node intersects viewport', async () => { @@ -114,7 +114,7 @@ test('Triggers callback only after node intersects viewport', async () => { // does not translate because the node does not intersect the container expect(translator.mock.calls).toEqual([]); - expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // change coordinates, the node is now inside the viewport mockBoundingClientRect(div, { @@ -129,7 +129,7 @@ test('Triggers callback only after node intersects viewport', async () => { document.dispatchEvent(new Event('scroll')); await awaitTranslation(); expect(translator.mock.calls).toEqual([[div.childNodes[0]]]); - expect(div.textContent).toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); }); test('Does not triggers callback for node that does not intersect viewport after scrolling', async () => { @@ -158,7 +158,7 @@ test('Does not triggers callback for node that does not intersect viewport after // does not translate because the element does not intersect the container expect(translator.mock.calls).toEqual([]); - expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // change coordinates, the node is still outside the viewport mockBoundingClientRect(div, { @@ -175,5 +175,5 @@ test('Does not triggers callback for node that does not intersect viewport after // still not translated expect(translator.mock.calls).toEqual([]); - expect(div.textContent).not.toMatch(containsRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); }); From 035787f42a4dfc885a1c9ead1ad5f22db3d577c1 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 20 Jun 2025 12:41:04 +0200 Subject: [PATCH 267/313] test: add test case, improve checks --- src/__tests__/DOMNodesTranslator.test.ts | 109 ++++++++++++++++++++--- 1 file changed, 96 insertions(+), 13 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 94d51a8..44087ca 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -7,6 +7,12 @@ import { translator, } from './utils'; +function getAttributeNode(node: Element, attrName: string) { + const attrNode = node.getAttributeNode(attrName); + if (!attrNode) throw new Error('Not found node for test'); + return attrNode; +} + test('Translates a node and restores the original node text', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const text = 'Hello world!'; @@ -42,7 +48,7 @@ test('Stores original node text on translation and clears it after restoration', expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); }); -test('Stores the node after translation and removes it after restoration', async () => { +test('hasNode returns true if node is currently translated and false if not', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const text = 'Hello world!'; const div = document.createElement('div'); @@ -61,30 +67,94 @@ test('Stores the node after translation and removes it after restoration', async expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(false); }); -test('UpdateNode method translates the modified node', async () => { +test('updateNode method translates the modified node', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const div = document.createElement('div'); const text1 = 'title text'; div.setAttribute('title', text1); + const attrNode = getAttributeNode(div, 'title'); + // translate - domNodesTranslator.translateNode(div.attributes[0]); + domNodesTranslator.translateNode(attrNode); await awaitTranslation(); - expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(attrNode.value).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // update value const text2 = 'title text is update'; div.setAttribute('title', text2); - domNodesTranslator.updateNode(div.attributes[0]); + domNodesTranslator.updateNode(attrNode); await awaitTranslation(); // check that the node value is the translated new value - expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); - expect(div.getAttribute('title')).toMatch(text2); + expect(attrNode.value).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(attrNode.value).toContain(text2); domNodesTranslator.restoreNode(div.attributes[0]); - expect(div.getAttribute('title')).toBe(text2); + expect(attrNode.value).toBe(text2); +}); + +test('Calls the callback after a node is translated and updated', async () => { + const callback = vi.fn(); + + const domNodesTranslator = new DOMNodesTranslator(translator); + const div = document.createElement('div'); + const text1 = 'title text'; + div.setAttribute('title', text1); + + const attrNode = getAttributeNode(div, 'title'); + domNodesTranslator.translateNode(attrNode, callback); + await awaitTranslation(); + + expect(attrNode.value).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(callback.mock.calls[0]).toEqual([attrNode]); + + const text2 = 'update title text'; + div.setAttribute('title', text2); + domNodesTranslator.updateNode(attrNode, callback); + await awaitTranslation(); + + expect(attrNode.value).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(attrNode.value).toContain(text2); + expect(callback.mock.calls[1]).toEqual([attrNode]); +}); + +test('A callback passed to updateNode is not called for nodes that were never translated', async () => { + const callback = vi.fn(); + + const domNodesTranslator = new DOMNodesTranslator(translator); + const div = document.createElement('div'); + const text1 = 'title text'; + div.setAttribute('title', text1); + + const attrNode = getAttributeNode(div, 'title'); + + domNodesTranslator.updateNode(attrNode, callback); + await awaitTranslation(); + expect(attrNode.value).toBe(text1); + expect(callback.mock.calls).toEqual([]); +}); + +test('Callback is not called when translating the same node again', async () => { + const callback = vi.fn(); + + const domNodesTranslator = new DOMNodesTranslator(translator); + const div = document.createElement('div'); + const text1 = 'title text'; + div.setAttribute('title', text1); + + const attrNode = getAttributeNode(div, 'title'); + + domNodesTranslator.translateNode(attrNode, callback); + await awaitTranslation(); + + expect(attrNode.value).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(callback.mock.calls[0]).toEqual([attrNode]); + + domNodesTranslator.translateNode(attrNode, callback); + await awaitTranslation(); + expect(callback).toBeCalledTimes(1); }); test('Callback is called only once after latest completed translation', async () => { @@ -107,27 +177,40 @@ test('Callback is called only once after latest completed translation', async () const text1 = 'Hello world!'; div.setAttribute('title', text1); + const attrNode = getAttributeNode(div, 'title'); + // first slow translation (300ms) - domNodesTranslator.translateNode(div.attributes[0], callback); + domNodesTranslator.translateNode(attrNode, callback); // waiting (less then 300 ms); the translation is not completed yet, callback should not be called await delay(100); await awaitTranslation(); expect(callback).toBeCalledTimes(0); - expect(div.getAttribute('title')).toBe(text1); + expect(attrNode.value).toBe(text1); // second fast translation (100ms) - div.setAttribute('title', 'Hi friends!'); - domNodesTranslator.updateNode(div.attributes[0], callback); + const text2 = 'Hi friends!'; + div.setAttribute('title', text2); + domNodesTranslator.updateNode(attrNode, callback); // waiting (more then 100 ms), the translation is complete and the callback should be called await delay(100); await awaitTranslation(); expect(callback).toBeCalledTimes(1); - expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(attrNode.value).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(attrNode.value).toContain(text2); + + // the second (fast translation) was resolved + await expect(translatorWithDelay.mock.results[1].value).resolves.toBeDefined(); // wait for the first translation to finish. Callback should not be called again await delay(200); await awaitTranslation(); expect(callback).toBeCalledTimes(1); + + // all translation was resolved + expect(translatorWithDelay.mock.results).toHaveLength(2); + + // the first (slow translation) was resolved + await expect(translatorWithDelay.mock.results[0].value).resolves.toBeDefined(); }); From 0733e37ae0613ec92daf118dd512ea1a6e614f3c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 20 Jun 2025 12:56:23 +0200 Subject: [PATCH 268/313] chore: use ready variable --- src/__tests__/DOMNodesTranslator.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 44087ca..3cca501 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -91,7 +91,7 @@ test('updateNode method translates the modified node', async () => { expect(attrNode.value).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); expect(attrNode.value).toContain(text2); - domNodesTranslator.restoreNode(div.attributes[0]); + domNodesTranslator.restoreNode(attrNode); expect(attrNode.value).toBe(text2); }); From a93223bab772ef48b566c97fcf902a502630d0bb Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 20 Jun 2025 13:04:29 +0200 Subject: [PATCH 269/313] chore: improve names --- src/__tests__/DOMNodesTranslator.test.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 3cca501..e970369 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -125,14 +125,15 @@ test('A callback passed to updateNode is not called for nodes that were never tr const domNodesTranslator = new DOMNodesTranslator(translator); const div = document.createElement('div'); - const text1 = 'title text'; - div.setAttribute('title', text1); + const text = 'title text'; + div.setAttribute('title', text); const attrNode = getAttributeNode(div, 'title'); + // the node was not translated domNodesTranslator.updateNode(attrNode, callback); await awaitTranslation(); - expect(attrNode.value).toBe(text1); + expect(attrNode.value).toBe(text); expect(callback.mock.calls).toEqual([]); }); @@ -141,8 +142,8 @@ test('Callback is not called when translating the same node again', async () => const domNodesTranslator = new DOMNodesTranslator(translator); const div = document.createElement('div'); - const text1 = 'title text'; - div.setAttribute('title', text1); + const text = 'title text'; + div.setAttribute('title', text); const attrNode = getAttributeNode(div, 'title'); @@ -154,6 +155,10 @@ test('Callback is not called when translating the same node again', async () => domNodesTranslator.translateNode(attrNode, callback); await awaitTranslation(); + + // node was not changed + expect(attrNode.value).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(attrNode.value).toContain(text); expect(callback).toBeCalledTimes(1); }); From 129ccbf6668f2577e8088aafc2758d76c5a91165 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 20 Jun 2025 14:09:34 +0200 Subject: [PATCH 270/313] test: improve test name and checks --- .../NodesTranslator.preventRecursion.test.ts | 55 ++++++++----------- 1 file changed, 23 insertions(+), 32 deletions(-) diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index 19f54ce..895af9b 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -30,39 +30,21 @@ function buildTranslationServices(translator: TranslatorInterface) { return { nodesTranslator, updateNodeSpy }; } -test('Translation of nodes does not trigger recursive updateNode calls', async () => { +test('Translation of node does not trigger recursive translation', async () => { const { nodesTranslator, updateNodeSpy } = buildTranslationServices(translator); - const div1 = document.createElement('div'); - div1.textContent = 'Simple text'; - document.body.appendChild(div1); - - nodesTranslator.observe(div1); - await awaitTranslation(); - expect(div1.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); - await awaitTranslation(); - expect(updateNodeSpy.mock.calls).toEqual([]); - - // add new element - const div2 = document.createElement('div'); - div2.textContent = 'New text'; - div1.appendChild(div2); - - await awaitTranslation(); - expect(div2.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); - await awaitTranslation(); - expect(updateNodeSpy.mock.calls).toEqual([]); - - // add new attribute - div1.setAttribute('title', 'Short text'); + const div = document.createElement('div'); + div.textContent = 'Simple text'; + document.body.appendChild(div); + nodesTranslator.observe(div); await awaitTranslation(); - expect(div1.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); await awaitTranslation(); expect(updateNodeSpy.mock.calls).toEqual([]); }); -test('Updating a node does not trigger recursive updateNode calls', async () => { +test('Updating a node does not trigger recursive translation', async () => { const { nodesTranslator, updateNodeSpy } = buildTranslationServices(translator); const div = document.createElement('div'); @@ -80,15 +62,15 @@ test('Updating a node does not trigger recursive updateNode calls', async () => div.setAttribute('title', text); await awaitTranslation(); - // second arg is a callback, not relevant for this test - expect(updateNodeSpy.mock.calls).toEqual([[div.attributes[0], expect.any(Function)]]); + expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.getAttributeNode('title')); expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(div.getAttribute('title')).toContain(text); await awaitTranslation(); expect(updateNodeSpy).toBeCalledTimes(1); }); -test('Updating a node with a translated-looking value not trigger recursive updateNode calls', async () => { +test('Does not trigger recursive translation when setting node value starting with translation symbol', async () => { const { nodesTranslator, updateNodeSpy } = buildTranslationServices(translator); const div = document.createElement('div'); @@ -107,8 +89,9 @@ test('Updating a node with a translated-looking value not trigger recursive upda div.setAttribute('title', text2); await awaitTranslation(); - // second arg is a callback, not relevant for this test - expect(updateNodeSpy.mock.calls).toEqual([[div.attributes[0], expect.any(Function)]]); + expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.getAttributeNode('title')); + + // the node value should be: TRANSLATION_SYMBOL+TRANSLATION_SYMBOL+some text expect(div.getAttribute('title')).toBe(TRANSLATION_SYMBOL + text2); await awaitTranslation(); @@ -155,12 +138,20 @@ test('Only the latest translation will be applied to the node', async () => { await delay(100); await awaitTranslation(); expect(translatorWithDelay).toHaveBeenCalledTimes(2); + await expect(translatorWithDelay.mock.results[1].value).resolves.toBeDefined(); + expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(div.getAttribute('title')).toContain(text2); - // wait for first translation to finish; translation not applied, node remains unchanged + // wait for first (slow) translation to finish; translation not applied, node not changed await delay(200); await awaitTranslation(); - expect(div.getAttribute('title')).toMatch(text2); + expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(div.getAttribute('title')).toContain(text2); + + // all translations was resolved + expect(translatorWithDelay.mock.results).toHaveLength(2); + await expect(translatorWithDelay.mock.results[0].value).resolves.toBeDefined(); // reset nodesTranslator.unobserve(div); From e32a51f0d9e3f7e8f70a64e228f7383e21addc67 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 20 Jun 2025 14:45:42 +0200 Subject: [PATCH 271/313] test: change spy method --- .../NodesTranslator.preventRecursion.test.ts | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index 895af9b..34509a7 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -15,7 +15,9 @@ beforeEach(() => { }); function buildTranslationServices(translator: TranslatorInterface) { - const domNodeTranslator = new DOMNodesTranslator(translator); + const translationSpy = vi.fn(translator); + + const domNodeTranslator = new DOMNodesTranslator(translationSpy); const dispatcher = new TranslationDispatcher({ filter: () => true, nodesTranslator: domNodeTranslator, @@ -25,13 +27,11 @@ function buildTranslationServices(translator: TranslatorInterface) { nodesTranslator: domNodeTranslator, }); - const updateNodeSpy = vi.spyOn(dispatcher, 'updateNode'); - - return { nodesTranslator, updateNodeSpy }; + return { nodesTranslator, translationSpy }; } test('Translation of node does not trigger recursive translation', async () => { - const { nodesTranslator, updateNodeSpy } = buildTranslationServices(translator); + const { nodesTranslator, translationSpy } = buildTranslationServices(translator); const div = document.createElement('div'); div.textContent = 'Simple text'; @@ -40,12 +40,14 @@ test('Translation of node does not trigger recursive translation', async () => { nodesTranslator.observe(div); await awaitTranslation(); expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + + // translated without recursion await awaitTranslation(); - expect(updateNodeSpy.mock.calls).toEqual([]); + expect(translationSpy).toBeCalledTimes(1); }); test('Updating a node does not trigger recursive translation', async () => { - const { nodesTranslator, updateNodeSpy } = buildTranslationServices(translator); + const { nodesTranslator, translationSpy } = buildTranslationServices(translator); const div = document.createElement('div'); div.setAttribute('title', 'title text'); @@ -54,24 +56,25 @@ test('Updating a node does not trigger recursive translation', async () => { nodesTranslator.observe(div); await awaitTranslation(); expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + + // translated without recursion await awaitTranslation(); - expect(updateNodeSpy.mock.calls).toEqual([]); + expect(translationSpy).toBeCalledTimes(1); - // update content, node should be translated without triggering recursion + // update content, translate node without triggering recursion const text = 'new text'; div.setAttribute('title', text); await awaitTranslation(); - - expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.getAttributeNode('title')); expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); expect(div.getAttribute('title')).toContain(text); + // translated on update, no recursion await awaitTranslation(); - expect(updateNodeSpy).toBeCalledTimes(1); + expect(translationSpy).toBeCalledTimes(2); }); test('Does not trigger recursive translation when setting node value starting with translation symbol', async () => { - const { nodesTranslator, updateNodeSpy } = buildTranslationServices(translator); + const { nodesTranslator, translationSpy } = buildTranslationServices(translator); const div = document.createElement('div'); const text1 = 'title text'; @@ -82,20 +85,17 @@ test('Does not trigger recursive translation when setting node value starting wi await awaitTranslation(); expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); await awaitTranslation(); - expect(updateNodeSpy.mock.calls).toEqual([]); + expect(translationSpy).toBeCalledTimes(1); // update content, node should be translated without triggering recursion const text2 = TRANSLATION_SYMBOL + text1; div.setAttribute('title', text2); await awaitTranslation(); - expect(updateNodeSpy.mock.calls[0][0]).toEqual(div.getAttributeNode('title')); - // the node value should be: TRANSLATION_SYMBOL+TRANSLATION_SYMBOL+some text expect(div.getAttribute('title')).toBe(TRANSLATION_SYMBOL + text2); - await awaitTranslation(); - expect(updateNodeSpy).toHaveBeenCalledTimes(1); + expect(translationSpy).toHaveBeenCalledTimes(2); // restored node has the latest text nodesTranslator.unobserve(div); @@ -138,20 +138,21 @@ test('Only the latest translation will be applied to the node', async () => { await delay(100); await awaitTranslation(); expect(translatorWithDelay).toHaveBeenCalledTimes(2); - await expect(translatorWithDelay.mock.results[1].value).resolves.toBeDefined(); + // second translation was resolved + await expect(translatorWithDelay.mock.results[1].value).resolves.toBeDefined(); expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); expect(div.getAttribute('title')).toContain(text2); // wait for first (slow) translation to finish; translation not applied, node not changed await delay(200); await awaitTranslation(); - expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); - expect(div.getAttribute('title')).toContain(text2); - // all translations was resolved + // all translations was resolved (including the first slow translation) expect(translatorWithDelay.mock.results).toHaveLength(2); await expect(translatorWithDelay.mock.results[0].value).resolves.toBeDefined(); + expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(div.getAttribute('title')).toContain(text2); // reset nodesTranslator.unobserve(div); From 0ae5297995e82408c779e006b7ecf08806bad33a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 20 Jun 2025 17:01:24 +0200 Subject: [PATCH 272/313] chore: improve name, checks --- src/__tests__/NodesTranslator.preventRecursion.test.ts | 8 ++++---- src/__tests__/TranslationDispatcher.test.ts | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index 34509a7..beaba26 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -73,7 +73,7 @@ test('Updating a node does not trigger recursive translation', async () => { expect(translationSpy).toBeCalledTimes(2); }); -test('Does not trigger recursive translation when setting node value starting with translation symbol', async () => { +test('Does not trigger recursive translation when setting node value containing the translation symbol', async () => { const { nodesTranslator, translationSpy } = buildTranslationServices(translator); const div = document.createElement('div'); @@ -95,7 +95,7 @@ test('Does not trigger recursive translation when setting node value starting wi // the node value should be: TRANSLATION_SYMBOL+TRANSLATION_SYMBOL+some text expect(div.getAttribute('title')).toBe(TRANSLATION_SYMBOL + text2); await awaitTranslation(); - expect(translationSpy).toHaveBeenCalledTimes(2); + expect(translationSpy).toBeCalledTimes(2); // restored node has the latest text nodesTranslator.unobserve(div); @@ -127,7 +127,7 @@ test('Only the latest translation will be applied to the node', async () => { // waiting (less then 300 ms); the translation is not completed yet, node not changed await delay(100); await awaitTranslation(); - expect(translatorWithDelay).toHaveBeenCalledTimes(1); + expect(translatorWithDelay).toBeCalledTimes(1); expect(div.getAttribute('title')).toBe(text1); // second fast translation (100ms) @@ -137,7 +137,7 @@ test('Only the latest translation will be applied to the node', async () => { // waiting (more then 100 ms); the translation is complete and node was changed await delay(100); await awaitTranslation(); - expect(translatorWithDelay).toHaveBeenCalledTimes(2); + expect(translatorWithDelay).toBeCalledTimes(2); // second translation was resolved await expect(translatorWithDelay.mock.results[1].value).resolves.toBeDefined(); diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index cfc7bee..f68c077 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -67,6 +67,7 @@ test('In lazy-translation mode node not attached to document.body translate imme mockBoundingClientRect(div, { width: 50, height: 100, x: 0, y: 300 }); mockBoundingClientRect(document.body, { width: 100, height: 200, x: 0, y: 0 }); + // the element is translated regardless of viewport intersection translationDispatcher.translateNode(div); await awaitTranslation(); expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); From c38bf66838473ebfbcc1cf7a6dc4059ed7ff5095 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 23 Jun 2025 23:32:11 +0200 Subject: [PATCH 273/313] test: remove test case, add --- .../NodesTranslator.preventRecursion.test.ts | 125 +++++++++--------- 1 file changed, 61 insertions(+), 64 deletions(-) diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index beaba26..ed40cf3 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -2,7 +2,6 @@ import { DOMNodesTranslator } from '../DOMNodesTranslator'; import { TranslationDispatcher } from '../TranslationDispatcher'; import { awaitTranslation, - delay, startsWithRegex, TRANSLATION_SYMBOL, translator, @@ -73,88 +72,86 @@ test('Updating a node does not trigger recursive translation', async () => { expect(translationSpy).toBeCalledTimes(2); }); -test('Does not trigger recursive translation when setting node value containing the translation symbol', async () => { +test('Changes nodes not trigger recursive translation', async () => { const { nodesTranslator, translationSpy } = buildTranslationServices(translator); - const div = document.createElement('div'); - const text1 = 'title text'; - div.setAttribute('title', text1); - document.body.appendChild(div); + // create parent node + const parentDiv = document.createElement('div'); + document.body.appendChild(parentDiv); - nodesTranslator.observe(div); - await awaitTranslation(); - expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + nodesTranslator.observe(parentDiv); await awaitTranslation(); - expect(translationSpy).toBeCalledTimes(1); - // update content, node should be translated without triggering recursion - const text2 = TRANSLATION_SYMBOL + text1; - div.setAttribute('title', text2); - await awaitTranslation(); + expect(translationSpy).toBeCalledTimes(0); + vi.clearAllMocks(); + + // add empty element + const div1 = document.createElement('div'); + parentDiv.appendChild(div1); - // the node value should be: TRANSLATION_SYMBOL+TRANSLATION_SYMBOL+some text - expect(div.getAttribute('title')).toBe(TRANSLATION_SYMBOL + text2); await awaitTranslation(); - expect(translationSpy).toBeCalledTimes(2); + expect(translationSpy).toBeCalledTimes(0); + vi.clearAllMocks(); - // restored node has the latest text - nodesTranslator.unobserve(div); - expect(div.getAttribute('title')).toBe(text2); -}); + // add text in element + const textNode1 = new Text('Simple text'); + parentDiv.appendChild(textNode1); -test('Only the latest translation will be applied to the node', async () => { - // first translation call resolves after 300 ms, second — after 100 ms - const translatorWithDelay = vi - .fn() - .mockImplementationOnce( - (text: string) => - new Promise((res) => setTimeout(() => res(translator(text)), 300)), - ) - .mockImplementationOnce( - (text: string) => - new Promise((res) => setTimeout(() => res(translator(text)), 100)), - ); - const { nodesTranslator } = buildTranslationServices(translatorWithDelay); + await awaitTranslation(); + expect(textNode1.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(translationSpy).toBeCalledTimes(1); + vi.clearAllMocks(); - const div = document.createElement('div'); - const text1 = 'title text'; - div.setAttribute('title', text1); - document.body.appendChild(div); + // add element with text + const div2 = document.createElement('div'); + const textNode2 = new Text('New text'); + div2.appendChild(textNode2); - // first slow translation (300ms) - nodesTranslator.observe(div); + parentDiv.appendChild(div2); - // waiting (less then 300 ms); the translation is not completed yet, node not changed - await delay(100); await awaitTranslation(); - expect(translatorWithDelay).toBeCalledTimes(1); - expect(div.getAttribute('title')).toBe(text1); + expect(textNode2.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(translationSpy).toBeCalledTimes(1); + vi.clearAllMocks(); - // second fast translation (100ms) - const text2 = 'new title text'; - div.setAttribute('title', text2); + // text in element changed + const text1 = 'Update text'; + textNode2.nodeValue = text1; + await awaitTranslation(); + + expect(textNode2.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(textNode2.nodeValue).toContain(text1); + expect(translationSpy).toBeCalledTimes(1); + vi.clearAllMocks(); + + // add attribute + const attrNode = document.createAttribute('title'); + attrNode.nodeValue = 'Title text'; + parentDiv.setAttributeNode(attrNode); - // waiting (more then 100 ms); the translation is complete and node was changed - await delay(100); await awaitTranslation(); - expect(translatorWithDelay).toBeCalledTimes(2); + expect(attrNode.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(translationSpy).toBeCalledTimes(1); + vi.clearAllMocks(); - // second translation was resolved - await expect(translatorWithDelay.mock.results[1].value).resolves.toBeDefined(); - expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); - expect(div.getAttribute('title')).toContain(text2); + // remove attribute + parentDiv.removeAttributeNode(attrNode); + await awaitTranslation(); + expect(translationSpy).toBeCalledTimes(0); + vi.clearAllMocks(); - // wait for first (slow) translation to finish; translation not applied, node not changed - await delay(200); + // removed text node + parentDiv.removeChild(textNode1); await awaitTranslation(); + expect(translationSpy).toBeCalledTimes(0); + vi.clearAllMocks(); - // all translations was resolved (including the first slow translation) - expect(translatorWithDelay.mock.results).toHaveLength(2); - await expect(translatorWithDelay.mock.results[0].value).resolves.toBeDefined(); - expect(div.getAttribute('title')).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); - expect(div.getAttribute('title')).toContain(text2); + // removed element + parentDiv.removeChild(div2); + await awaitTranslation(); + expect(translationSpy).toBeCalledTimes(0); + vi.clearAllMocks(); - // reset - nodesTranslator.unobserve(div); - expect(div.getAttribute('title')).toBe(text2); + await awaitTranslation(); + expect(translationSpy).toBeCalledTimes(0); }); From 9d23c63db253a1300484bba80af387bd3ca4fbb8 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 23 Jun 2025 23:48:33 +0200 Subject: [PATCH 274/313] refactor: use fail fast --- src/DOMNodesTranslator.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 9b36f16..41bfd46 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -75,7 +75,7 @@ export class DOMNodesTranslator { * After translation calls the callback with the translated node */ public translateNode = (node: Node, callback?: NodeTranslatedCallback) => { - if (this.hasNode(node)) return; + if (this.hasNode(node)) throw new Error('This node has already been translated'); // Skip empty text if (node.nodeValue === null || node.nodeValue.trim().length == 0) return; @@ -95,7 +95,8 @@ export class DOMNodesTranslator { */ public restoreNode(node: Node) { const nodeData = this.nodeStorage.get(node); - if (!nodeData) return; + if (!nodeData) + throw new Error('Node cannot be restored because it was never translated'); if (nodeData.originalText !== null) { node.nodeValue = nodeData.originalText; @@ -109,7 +110,8 @@ export class DOMNodesTranslator { */ public updateNode(node: Node, callback?: NodeTranslatedCallback) { const nodeData = this.nodeStorage.get(node); - if (!nodeData) return; + if (!nodeData) + throw new Error('Node cannot be updated because it was never translated'); nodeData.updateId++; this.translateNodeContent(node, callback); From 6c2bc1f786d5d17f28ee3c3c76ae987b6ff6bc73 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 23 Jun 2025 23:55:09 +0200 Subject: [PATCH 275/313] test: improve test checks --- src/__tests__/DOMNodesTranslator.test.ts | 92 +++++++++++------------- 1 file changed, 40 insertions(+), 52 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index e970369..e58d737 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -16,64 +16,59 @@ function getAttributeNode(node: Element, attrName: string) { test('Translates a node and restores the original node text', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const text = 'Hello world!'; - const div = document.createElement('div'); - div.textContent = text; + const node = new Text(text); - domNodesTranslator.translateNode(div.childNodes[0]); + domNodesTranslator.translateNode(node); await awaitTranslation(); - expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(node.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); - domNodesTranslator.restoreNode(div.childNodes[0]); - expect(div.textContent).toBe(text); + domNodesTranslator.restoreNode(node); + expect(node.nodeValue).toBe(text); }); test('Stores original node text on translation and clears it after restoration', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const text = 'Hello world!'; - const div = document.createElement('div'); - div.textContent = text; + const node = new Text(text); // before translation - expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); + expect(domNodesTranslator.getOriginalNodeText(node)).toBe(null); - domNodesTranslator.translateNode(div.childNodes[0]); + domNodesTranslator.translateNode(node); await awaitTranslation(); - expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); - expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toBe(text); + expect(node.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(domNodesTranslator.getOriginalNodeText(node)).toBe(text); // after restore - domNodesTranslator.restoreNode(div.childNodes[0]); - expect(div.textContent).toBe(text); - expect(domNodesTranslator.getOriginalNodeText(div.childNodes[0])).toBe(null); + domNodesTranslator.restoreNode(node); + expect(node.nodeValue).toBe(text); + expect(domNodesTranslator.getOriginalNodeText(node)).toBe(null); }); test('hasNode returns true if node is currently translated and false if not', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const text = 'Hello world!'; - const div = document.createElement('div'); - div.textContent = text; + const node = new Text(text); // not exists before translate - expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(false); + expect(domNodesTranslator.hasNode(node)).toBe(false); - domNodesTranslator.translateNode(div.childNodes[0]); + domNodesTranslator.translateNode(node); await awaitTranslation(); - expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); - expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(true); + expect(node.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(domNodesTranslator.hasNode(node)).toBe(true); - domNodesTranslator.restoreNode(div.childNodes[0]); - expect(div.textContent).toBe(text); - expect(domNodesTranslator.hasNode(div.childNodes[0])).toBe(false); + domNodesTranslator.restoreNode(node); + expect(node.nodeValue).toBe(text); + expect(domNodesTranslator.hasNode(node)).toBe(false); }); test('updateNode method translates the modified node', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); - const div = document.createElement('div'); const text1 = 'title text'; - div.setAttribute('title', text1); - - const attrNode = getAttributeNode(div, 'title'); + const attrNode = document.createAttribute('title'); + attrNode.nodeValue = text1; // translate domNodesTranslator.translateNode(attrNode); @@ -82,7 +77,7 @@ test('updateNode method translates the modified node', async () => { // update value const text2 = 'title text is update'; - div.setAttribute('title', text2); + attrNode.nodeValue = text2; domNodesTranslator.updateNode(attrNode); await awaitTranslation(); @@ -99,36 +94,34 @@ test('Calls the callback after a node is translated and updated', async () => { const callback = vi.fn(); const domNodesTranslator = new DOMNodesTranslator(translator); - const div = document.createElement('div'); const text1 = 'title text'; - div.setAttribute('title', text1); + const attrNode = document.createAttribute('title'); + attrNode.nodeValue = text1; - const attrNode = getAttributeNode(div, 'title'); domNodesTranslator.translateNode(attrNode, callback); await awaitTranslation(); - expect(attrNode.value).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); - expect(callback.mock.calls[0]).toEqual([attrNode]); + expect(attrNode.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(callback.mock.calls).toEqual([[attrNode]]); const text2 = 'update title text'; - div.setAttribute('title', text2); + attrNode.nodeValue = text2; + domNodesTranslator.updateNode(attrNode, callback); await awaitTranslation(); - expect(attrNode.value).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); - expect(attrNode.value).toContain(text2); - expect(callback.mock.calls[1]).toEqual([attrNode]); + expect(attrNode.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(attrNode.nodeValue).toContain(text2); + expect(callback.mock.calls).toEqual([[attrNode], [attrNode]]); }); test('A callback passed to updateNode is not called for nodes that were never translated', async () => { const callback = vi.fn(); const domNodesTranslator = new DOMNodesTranslator(translator); - const div = document.createElement('div'); const text = 'title text'; - div.setAttribute('title', text); - - const attrNode = getAttributeNode(div, 'title'); + const attrNode = document.createAttribute('title'); + attrNode.nodeValue = text; // the node was not translated domNodesTranslator.updateNode(attrNode, callback); @@ -141,25 +134,20 @@ test('Callback is not called when translating the same node again', async () => const callback = vi.fn(); const domNodesTranslator = new DOMNodesTranslator(translator); - const div = document.createElement('div'); const text = 'title text'; - div.setAttribute('title', text); - - const attrNode = getAttributeNode(div, 'title'); + const attrNode = document.createAttribute('title'); + attrNode.nodeValue = text; domNodesTranslator.translateNode(attrNode, callback); await awaitTranslation(); expect(attrNode.value).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); - expect(callback.mock.calls[0]).toEqual([attrNode]); + expect(callback.mock.calls).toEqual([[attrNode]]); - domNodesTranslator.translateNode(attrNode, callback); await awaitTranslation(); + expect(domNodesTranslator.translateNode(attrNode, callback)).toThrowError(); - // node was not changed - expect(attrNode.value).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); - expect(attrNode.value).toContain(text); - expect(callback).toBeCalledTimes(1); + // TODO: check exception }); test('Callback is called only once after latest completed translation', async () => { From d55a826744fe94c35dc58d0194f4f8e066eeffdd Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 24 Jun 2025 14:48:48 +0200 Subject: [PATCH 276/313] test: append node after set value --- src/__tests__/NodesTranslator.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index 11bdcc3..e86a3a0 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -143,9 +143,9 @@ describe('basic usage', () => { await awaitTranslation(); const div1 = document.createElement('div'); + div1.innerHTML = 'Text 1'; document.body.appendChild(div1); - div1.innerHTML = 'Text 1'; await awaitTranslation(); expect(div1.innerHTML).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); From 6ee27c31f8aa34b3675fdea18b6bace5f982adfb Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 24 Jun 2025 15:33:04 +0200 Subject: [PATCH 277/313] refactor: add node type check --- src/DOMNodesTranslator.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 41bfd46..5ed8507 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -77,6 +77,13 @@ export class DOMNodesTranslator { public translateNode = (node: Node, callback?: NodeTranslatedCallback) => { if (this.hasNode(node)) throw new Error('This node has already been translated'); + // Translate only Text and Attr node + if (node.nodeType !== Node.ATTRIBUTE_NODE && node.nodeType !== Node.TEXT_NODE) { + throw new Error( + 'Can not translate node: only Text and Attr nodes are supported', + ); + } + // Skip empty text if (node.nodeValue === null || node.nodeValue.trim().length == 0) return; From f69e30521de607192a9307da9d611d37f49b5d2b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 24 Jun 2025 15:33:54 +0200 Subject: [PATCH 278/313] feat: do not throw error on restore --- src/DOMNodesTranslator.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 5ed8507..16a7a32 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -102,8 +102,7 @@ export class DOMNodesTranslator { */ public restoreNode(node: Node) { const nodeData = this.nodeStorage.get(node); - if (!nodeData) - throw new Error('Node cannot be restored because it was never translated'); + if (!nodeData) return; if (nodeData.originalText !== null) { node.nodeValue = nodeData.originalText; From 2152aeadf7fde888fafdd06eca15d3d372c0431a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 24 Jun 2025 15:36:13 +0200 Subject: [PATCH 279/313] test: improve test checks --- src/__tests__/DOMNodesTranslator.test.ts | 41 ++++++++---------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index e58d737..46285a4 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -7,12 +7,6 @@ import { translator, } from './utils'; -function getAttributeNode(node: Element, attrName: string) { - const attrNode = node.getAttributeNode(attrName); - if (!attrNode) throw new Error('Not found node for test'); - return attrNode; -} - test('Translates a node and restores the original node text', async () => { const domNodesTranslator = new DOMNodesTranslator(translator); const text = 'Hello world!'; @@ -115,7 +109,7 @@ test('Calls the callback after a node is translated and updated', async () => { expect(callback.mock.calls).toEqual([[attrNode], [attrNode]]); }); -test('A callback passed to updateNode is not called for nodes that were never translated', async () => { +test('updateNode throws an error when called on a node that was never translated', async () => { const callback = vi.fn(); const domNodesTranslator = new DOMNodesTranslator(translator); @@ -124,13 +118,12 @@ test('A callback passed to updateNode is not called for nodes that were never tr attrNode.nodeValue = text; // the node was not translated - domNodesTranslator.updateNode(attrNode, callback); - await awaitTranslation(); - expect(attrNode.value).toBe(text); - expect(callback.mock.calls).toEqual([]); + expect(() => { + domNodesTranslator.updateNode(attrNode, callback); + }).toThrowError(); }); -test('Callback is not called when translating the same node again', async () => { +test('translateNode throws an error when called on the same node more than once', async () => { const callback = vi.fn(); const domNodesTranslator = new DOMNodesTranslator(translator); @@ -145,9 +138,7 @@ test('Callback is not called when translating the same node again', async () => expect(callback.mock.calls).toEqual([[attrNode]]); await awaitTranslation(); - expect(domNodesTranslator.translateNode(attrNode, callback)).toThrowError(); - - // TODO: check exception + expect(() => domNodesTranslator.translateNode(attrNode, callback)).toThrowError(); }); test('Callback is called only once after latest completed translation', async () => { @@ -166,11 +157,9 @@ test('Callback is called only once after latest completed translation', async () ); const domNodesTranslator = new DOMNodesTranslator(translatorWithDelay); - const div = document.createElement('div'); - const text1 = 'Hello world!'; - div.setAttribute('title', text1); - - const attrNode = getAttributeNode(div, 'title'); + const text1 = 'title text'; + const attrNode = document.createAttribute('title'); + attrNode.nodeValue = text1; // first slow translation (300ms) domNodesTranslator.translateNode(attrNode, callback); @@ -183,7 +172,7 @@ test('Callback is called only once after latest completed translation', async () // second fast translation (100ms) const text2 = 'Hi friends!'; - div.setAttribute('title', text2); + attrNode.nodeValue = text2; domNodesTranslator.updateNode(attrNode, callback); // waiting (more then 100 ms), the translation is complete and the callback should be called @@ -193,17 +182,13 @@ test('Callback is called only once after latest completed translation', async () expect(attrNode.value).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); expect(attrNode.value).toContain(text2); - // the second (fast translation) was resolved - await expect(translatorWithDelay.mock.results[1].value).resolves.toBeDefined(); - // wait for the first translation to finish. Callback should not be called again await delay(200); await awaitTranslation(); expect(callback).toBeCalledTimes(1); // all translation was resolved - expect(translatorWithDelay.mock.results).toHaveLength(2); - - // the first (slow translation) was resolved - await expect(translatorWithDelay.mock.results[0].value).resolves.toBeDefined(); + await expect( + Promise.all(translatorWithDelay.mock.results.map((r) => r.value)), + ).resolves.toHaveLength(2); }); From 2e9b198b18c459c8ff13058f9a3c3219b41176e8 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 24 Jun 2025 15:51:57 +0200 Subject: [PATCH 280/313] test: improve --- src/__tests__/TranslationDispatcher.test.ts | 16 ++++--- src/lib/NodesIntersectionObserver.test.ts | 53 ++++++++++++--------- 2 files changed, 39 insertions(+), 30 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index f68c077..2dd9178 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -14,7 +14,7 @@ require('intersection-observer'); beforeEach(() => { document.body.innerHTML = ''; - mockBoundingClientRect(document.body, { width: 0, height: 0, x: 0, y: 0 }); + mockBoundingClientRect(document.body, { width: 100, height: 100, x: 0, y: 0 }); vi.clearAllMocks(); }); @@ -127,7 +127,7 @@ test('Callback is called after the node is restored', async () => { test('Does not translate ignored node', async () => { const filter = configureTranslatableNodePredicate({ - ignoredSelectors: ['comment'], + ignoredSelectors: ['title'], }); const translationDispatcher = new TranslationDispatcher({ filter, @@ -137,9 +137,10 @@ test('Does not translate ignored node', async () => { const div = document.createElement('div'); div.textContent = 'I`m container node'; - const text = 'I`m comment node'; - const comment = document.createComment(text); - div.appendChild(comment); + const text = 'I`m title '; + const attrNode = document.createAttribute('title'); + attrNode.nodeValue = text; + div.setAttributeNode(attrNode); document.body.appendChild(div); translationDispatcher.translateNode(div); @@ -147,6 +148,7 @@ test('Does not translate ignored node', async () => { expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); - // comment not translated - expect(comment.textContent).toBe(text); + // not translated + expect(attrNode.nodeValue).toBe(text); + expect(attrNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); }); diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index 5961c24..04f02ce 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -14,8 +14,8 @@ const translator = vi.fn().mockImplementation(async (node: Node) => { beforeEach(() => { mockBoundingClientRect(document.body, { - width: 0, - height: 0, + width: 100, + height: 100, x: 0, y: 0, }); @@ -24,18 +24,17 @@ beforeEach(() => { }); test('Triggers callback for node in viewport', async () => { - const div = document.createElement('div'); - div.textContent = 'Hello, World!'; - document.body.appendChild(div); + const textNode = new Text('Hello, World!'); + document.body.appendChild(textNode); const lazyTranslator = new NodesIntersectionObserver(); - lazyTranslator.observe(div.childNodes[0], translator); + lazyTranslator.observe(textNode, translator); await awaitTranslation(); // The mock function was called once - expect(translator.mock.calls).toEqual([[div.childNodes[0]]]); - expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(translator.mock.calls).toEqual([[textNode]]); + expect(textNode.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); }); test('Triggers callback for a node only when it becomes intersectable', async () => { @@ -48,18 +47,20 @@ test('Triggers callback for a node only when it becomes intersectable', async () div.style.display = 'none'; document.body.appendChild(div); - lazyTranslator.observe(div.childNodes[0], translator); + const textNode = div.childNodes[0]; + + lazyTranslator.observe(textNode, translator); await awaitTranslation(); expect(translator.mock.calls).toEqual([]); - expect(div.textContent).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // the node becomes visible and is translated div.style.display = 'block'; await awaitTranslation(); - expect(translator.mock.calls).toEqual([[div.childNodes[0]]]); - expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(translator.mock.calls).toEqual([[textNode]]); + expect(textNode.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); }); test('Does not trigger callback after node is detached', async () => { @@ -71,21 +72,23 @@ test('Does not trigger callback after node is detached', async () => { div.style.display = 'none'; document.body.appendChild(div); - lazyTranslator.observe(div.childNodes[0], translator); + const textNode = div.childNodes[0]; + + lazyTranslator.observe(textNode, translator); await awaitTranslation(); // does not translate because node is not visible expect(translator.mock.calls).toEqual([]); - expect(div.textContent).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // node is detached - lazyTranslator.unobserve(div.childNodes[0]); + lazyTranslator.unobserve(textNode); // becomes visible and intersectable, but still does not translate after being detached div.style.display = 'block'; await awaitTranslation(); expect(translator.mock.calls).toEqual([]); - expect(div.textContent).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); }); test('Triggers callback only after node intersects viewport', async () => { @@ -94,6 +97,8 @@ test('Triggers callback only after node intersects viewport', async () => { div.textContent = 'Hello world!'; document.body.appendChild(div); + const textNode = div.childNodes[0]; + mockBoundingClientRect(document.body, { width: 300, height: 300, @@ -109,12 +114,12 @@ test('Triggers callback only after node intersects viewport', async () => { y: 500, }); - lazyTranslator.observe(div.childNodes[0], translator); + lazyTranslator.observe(textNode, translator); await awaitTranslation(); // does not translate because the node does not intersect the container expect(translator.mock.calls).toEqual([]); - expect(div.textContent).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // change coordinates, the node is now inside the viewport mockBoundingClientRect(div, { @@ -128,8 +133,8 @@ test('Triggers callback only after node intersects viewport', async () => { // The polyfill starts recalculating element positions only after the event document.dispatchEvent(new Event('scroll')); await awaitTranslation(); - expect(translator.mock.calls).toEqual([[div.childNodes[0]]]); - expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(translator.mock.calls).toEqual([[textNode]]); + expect(textNode.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); }); test('Does not triggers callback for node that does not intersect viewport after scrolling', async () => { @@ -138,6 +143,8 @@ test('Does not triggers callback for node that does not intersect viewport after div.textContent = 'Hello world!'; document.body.appendChild(div); + const textNode = div.childNodes[0]; + mockBoundingClientRect(document.body, { width: 300, height: 300, @@ -153,12 +160,12 @@ test('Does not triggers callback for node that does not intersect viewport after y: 400, }); - lazyTranslator.observe(div.childNodes[0], translator); + lazyTranslator.observe(textNode, translator); await awaitTranslation(); // does not translate because the element does not intersect the container expect(translator.mock.calls).toEqual([]); - expect(div.textContent).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // change coordinates, the node is still outside the viewport mockBoundingClientRect(div, { @@ -175,5 +182,5 @@ test('Does not triggers callback for node that does not intersect viewport after // still not translated expect(translator.mock.calls).toEqual([]); - expect(div.textContent).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); }); From 11f918def9178dea999e0cabcd4c081dac45199a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 24 Jun 2025 15:59:45 +0200 Subject: [PATCH 281/313] chore: fix typo --- src/__tests__/NodesTranslator.preventRecursion.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/NodesTranslator.preventRecursion.test.ts b/src/__tests__/NodesTranslator.preventRecursion.test.ts index ed40cf3..272632b 100644 --- a/src/__tests__/NodesTranslator.preventRecursion.test.ts +++ b/src/__tests__/NodesTranslator.preventRecursion.test.ts @@ -72,7 +72,7 @@ test('Updating a node does not trigger recursive translation', async () => { expect(translationSpy).toBeCalledTimes(2); }); -test('Changes nodes not trigger recursive translation', async () => { +test('Changed nodes do not trigger recursive translation', async () => { const { nodesTranslator, translationSpy } = buildTranslationServices(translator); // create parent node From 0a44b68896fb72eb5d75b8e377a48d64c12cd1dd Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 24 Jun 2025 16:45:37 +0200 Subject: [PATCH 282/313] Revert "test: append node after set value" This reverts commit e8fce889fa17ccfb2230b20c5ef3323528384d90. --- src/__tests__/NodesTranslator.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/NodesTranslator.test.ts b/src/__tests__/NodesTranslator.test.ts index e86a3a0..11bdcc3 100644 --- a/src/__tests__/NodesTranslator.test.ts +++ b/src/__tests__/NodesTranslator.test.ts @@ -143,9 +143,9 @@ describe('basic usage', () => { await awaitTranslation(); const div1 = document.createElement('div'); - div1.innerHTML = 'Text 1'; document.body.appendChild(div1); + div1.innerHTML = 'Text 1'; await awaitTranslation(); expect(div1.innerHTML).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); From f2adc5c207e9569ea8ef1e75903aea779a078b0e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 24 Jun 2025 20:42:48 +0200 Subject: [PATCH 283/313] feat: translate only not translated nodes --- src/NodesTranslator.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index 0a5173e..e6f2c02 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -39,6 +39,7 @@ export class NodesTranslator { this.observedNodesStorage.set(node, observer); observer.addHandler('elementAdded', ({ target }) => { + if (this.dispatcher.hasNode(target)) return; this.dispatcher.translateNode(target, (node: Node) => this.mutatedNodes.add(node), ); From 8e701728900ccec3d798a49e5eafad7a176b0c14 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 24 Jun 2025 20:48:59 +0200 Subject: [PATCH 284/313] chore: use early return --- src/NodesTranslator.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/NodesTranslator.ts b/src/NodesTranslator.ts index e6f2c02..8e5638c 100644 --- a/src/NodesTranslator.ts +++ b/src/NodesTranslator.ts @@ -70,18 +70,19 @@ export class NodesTranslator { } // NOTE: If need delete untracked nodes, we should keep relates like Element -> attributes - if (!this.dispatcher.hasNode(attribute)) { - this.dispatcher.translateNode(attribute, (node: Node) => - this.mutatedNodes.add(node), - ); - } else { + if (this.dispatcher.hasNode(attribute)) { this.dispatcher.updateNode(attribute, (node: Node) => this.mutatedNodes.add(node), ); + return; } + this.dispatcher.translateNode(attribute, (node: Node) => + this.mutatedNodes.add(node), + ); }); observer.observe(node); + this.dispatcher.translateNode(node, (node: Node) => this.mutatedNodes.add(node)); } From 61d6337ac2bc5e4b19cdcb00b13aa8052463c1f9 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 24 Jun 2025 21:03:39 +0200 Subject: [PATCH 285/313] chore: fix typo --- src/DOMNodesTranslator.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/DOMNodesTranslator.ts b/src/DOMNodesTranslator.ts index 16a7a32..45ee6e3 100644 --- a/src/DOMNodesTranslator.ts +++ b/src/DOMNodesTranslator.ts @@ -77,10 +77,9 @@ export class DOMNodesTranslator { public translateNode = (node: Node, callback?: NodeTranslatedCallback) => { if (this.hasNode(node)) throw new Error('This node has already been translated'); - // Translate only Text and Attr node if (node.nodeType !== Node.ATTRIBUTE_NODE && node.nodeType !== Node.TEXT_NODE) { throw new Error( - 'Can not translate node: only Text and Attr nodes are supported', + 'Cannot translate node: only Text and Attr nodes are supported', ); } From 1242a95852ad48726c251634ae406c693840c656 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 24 Jun 2025 21:03:57 +0200 Subject: [PATCH 286/313] chore: improve code --- src/__tests__/DOMNodesTranslator.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/__tests__/DOMNodesTranslator.test.ts b/src/__tests__/DOMNodesTranslator.test.ts index 46285a4..3fcaf86 100644 --- a/src/__tests__/DOMNodesTranslator.test.ts +++ b/src/__tests__/DOMNodesTranslator.test.ts @@ -67,21 +67,21 @@ test('updateNode method translates the modified node', async () => { // translate domNodesTranslator.translateNode(attrNode); await awaitTranslation(); - expect(attrNode.value).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(attrNode.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // update value - const text2 = 'title text is update'; + const text2 = 'title text is updated'; attrNode.nodeValue = text2; domNodesTranslator.updateNode(attrNode); await awaitTranslation(); // check that the node value is the translated new value - expect(attrNode.value).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); - expect(attrNode.value).toContain(text2); + expect(attrNode.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(attrNode.nodeValue).toContain(text2); domNodesTranslator.restoreNode(attrNode); - expect(attrNode.value).toBe(text2); + expect(attrNode.nodeValue).toBe(text2); }); test('Calls the callback after a node is translated and updated', async () => { @@ -134,7 +134,7 @@ test('translateNode throws an error when called on the same node more than once' domNodesTranslator.translateNode(attrNode, callback); await awaitTranslation(); - expect(attrNode.value).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(attrNode.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); expect(callback.mock.calls).toEqual([[attrNode]]); await awaitTranslation(); @@ -168,7 +168,7 @@ test('Callback is called only once after latest completed translation', async () await delay(100); await awaitTranslation(); expect(callback).toBeCalledTimes(0); - expect(attrNode.value).toBe(text1); + expect(attrNode.nodeValue).toBe(text1); // second fast translation (100ms) const text2 = 'Hi friends!'; @@ -179,8 +179,8 @@ test('Callback is called only once after latest completed translation', async () await delay(100); await awaitTranslation(); expect(callback).toBeCalledTimes(1); - expect(attrNode.value).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); - expect(attrNode.value).toContain(text2); + expect(attrNode.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(attrNode.nodeValue).toContain(text2); // wait for the first translation to finish. Callback should not be called again await delay(200); From 3e9f900ee98af72bdcf1aa12b0efb3897f7e3782 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 25 Jun 2025 17:36:39 +0200 Subject: [PATCH 287/313] test: add wrapper for improve readable --- src/lib/NodesIntersectionObserver.test.ts | 80 +++++++++-------------- 1 file changed, 31 insertions(+), 49 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index 04f02ce..706ef14 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -12,13 +12,29 @@ const translator = vi.fn().mockImplementation(async (node: Node) => { node.textContent = TRANSLATION_SYMBOL + node.textContent; }); -beforeEach(() => { - mockBoundingClientRect(document.body, { - width: 100, - height: 100, - x: 0, - y: 0, +const changeElementPosition = ( + node: HTMLElement, + position?: { + width?: number; + height?: number; + x?: number; + y?: number; + }, +) => { + mockBoundingClientRect(node, { + width: position?.width ?? 100, + height: position?.height ?? 100, + x: position?.x ?? 0, + y: position?.y ?? 0, }); + + // simulate a scroll event; the polyfill listens for the "scroll" event on the document + // The polyfill starts recalculating element positions only after the event + document.dispatchEvent(new Event('scroll')); +}; + +beforeEach(() => { + changeElementPosition(document.body); document.body.textContent = ''; vi.clearAllMocks(); }); @@ -99,20 +115,11 @@ test('Triggers callback only after node intersects viewport', async () => { const textNode = div.childNodes[0]; - mockBoundingClientRect(document.body, { - width: 300, - height: 300, - x: 0, - y: 0, - }); + // coordinates: x=0, y=0 + changeElementPosition(document.body, { width: 300, height: 300 }); // element is outside the viewport and does not intersect the container - mockBoundingClientRect(div, { - width: 100, - height: 100, - x: 0, - y: 500, - }); + changeElementPosition(div, { y: 500 }); lazyTranslator.observe(textNode, translator); await awaitTranslation(); @@ -121,17 +128,9 @@ test('Triggers callback only after node intersects viewport', async () => { expect(translator.mock.calls).toEqual([]); expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); - // change coordinates, the node is now inside the viewport - mockBoundingClientRect(div, { - width: 100, - height: 100, - x: 0, - y: 0, - }); + // change coordinates, the node is now inside the viewport (coordinates: x=0, y=0) + changeElementPosition(div); - // simulate a scroll event; the polyfill listens for the "scroll" event on the document - // The polyfill starts recalculating element positions only after the event - document.dispatchEvent(new Event('scroll')); await awaitTranslation(); expect(translator.mock.calls).toEqual([[textNode]]); expect(textNode.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); @@ -145,20 +144,11 @@ test('Does not triggers callback for node that does not intersect viewport after const textNode = div.childNodes[0]; - mockBoundingClientRect(document.body, { - width: 300, - height: 300, - x: 0, - y: 0, - }); + // coordinates: x=0, y=0 + changeElementPosition(document.body, { width: 300, height: 300 }); // node is outside the viewport and does not intersect the container - mockBoundingClientRect(div, { - width: 100, - height: 100, - x: 0, - y: 400, - }); + changeElementPosition(div, { y: 500 }); lazyTranslator.observe(textNode, translator); await awaitTranslation(); @@ -168,16 +158,8 @@ test('Does not triggers callback for node that does not intersect viewport after expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // change coordinates, the node is still outside the viewport - mockBoundingClientRect(div, { - width: 100, - height: 100, - x: 0, - y: 330, - }); + changeElementPosition(div, { y: 330 }); - // simulate a scroll event; the polyfill listens for the "scroll" event on the document - // The polyfill starts recalculating element positions only after the event - document.dispatchEvent(new Event('scroll')); await awaitTranslation(); // still not translated From b040de47d9a28fd87ce31d850ddbcb00d67f62b0 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 25 Jun 2025 17:59:19 +0200 Subject: [PATCH 288/313] test: add --- src/lib/NodesIntersectionObserver.test.ts | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index 706ef14..f791e22 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -39,6 +39,40 @@ beforeEach(() => { vi.clearAllMocks(); }); +describe('Trigger callback for nodes in viewport', () => { + const callback = vi.fn(); + + test('triggers for element', async () => { + const nodesIntersectionObserver = new NodesIntersectionObserver(); + const div = document.createElement('div'); + + nodesIntersectionObserver.observe(div, callback); + await awaitTranslation(); + + // mock was called for element + expect(callback.mock.calls).toEqual([[div]]); + }); + test('triggers for node', async () => { + const nodesIntersectionObserver = new NodesIntersectionObserver(); + const node = new Text(); + + nodesIntersectionObserver.observe(node, callback); + await awaitTranslation(); + + expect(callback.mock.calls).toEqual([[node]]); + }); + test('triggers for attribute', async () => { + const nodesIntersectionObserver = new NodesIntersectionObserver(); + const attr = document.createAttribute('title'); + attr.nodeValue = 'title text'; + + nodesIntersectionObserver.observe(attr, callback); + await awaitTranslation(); + + expect(callback.mock.calls).toEqual([[attr]]); + }); +}); + test('Triggers callback for node in viewport', async () => { const textNode = new Text('Hello, World!'); document.body.appendChild(textNode); From 84306c1a94dda43dcc71325a2e0a9534e6e86ed7 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 25 Jun 2025 18:00:44 +0200 Subject: [PATCH 289/313] chore: rename --- src/lib/NodesIntersectionObserver.test.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index f791e22..7106042 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -77,9 +77,9 @@ test('Triggers callback for node in viewport', async () => { const textNode = new Text('Hello, World!'); document.body.appendChild(textNode); - const lazyTranslator = new NodesIntersectionObserver(); + const nodesIntersectionObserver = new NodesIntersectionObserver(); - lazyTranslator.observe(textNode, translator); + nodesIntersectionObserver.observe(textNode, translator); await awaitTranslation(); // The mock function was called once @@ -88,7 +88,7 @@ test('Triggers callback for node in viewport', async () => { }); test('Triggers callback for a node only when it becomes intersectable', async () => { - const lazyTranslator = new NodesIntersectionObserver(); + const nodesIntersectionObserver = new NodesIntersectionObserver(); // node with display = 'none' is not intersectable // node with visibility: 'hidden' is considered intersectable, so use display: 'none' instead @@ -99,7 +99,7 @@ test('Triggers callback for a node only when it becomes intersectable', async () const textNode = div.childNodes[0]; - lazyTranslator.observe(textNode, translator); + nodesIntersectionObserver.observe(textNode, translator); await awaitTranslation(); expect(translator.mock.calls).toEqual([]); @@ -114,7 +114,7 @@ test('Triggers callback for a node only when it becomes intersectable', async () }); test('Does not trigger callback after node is detached', async () => { - const lazyTranslator = new NodesIntersectionObserver(); + const nodesIntersectionObserver = new NodesIntersectionObserver(); // node with display: none is not intersectable const div = document.createElement('div'); @@ -124,7 +124,7 @@ test('Does not trigger callback after node is detached', async () => { const textNode = div.childNodes[0]; - lazyTranslator.observe(textNode, translator); + nodesIntersectionObserver.observe(textNode, translator); await awaitTranslation(); // does not translate because node is not visible @@ -132,7 +132,7 @@ test('Does not trigger callback after node is detached', async () => { expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // node is detached - lazyTranslator.unobserve(textNode); + nodesIntersectionObserver.unobserve(textNode); // becomes visible and intersectable, but still does not translate after being detached div.style.display = 'block'; @@ -142,7 +142,7 @@ test('Does not trigger callback after node is detached', async () => { }); test('Triggers callback only after node intersects viewport', async () => { - const lazyTranslator = new NodesIntersectionObserver(); + const nodesIntersectionObserver = new NodesIntersectionObserver(); const div = document.createElement('div'); div.textContent = 'Hello world!'; document.body.appendChild(div); @@ -155,7 +155,7 @@ test('Triggers callback only after node intersects viewport', async () => { // element is outside the viewport and does not intersect the container changeElementPosition(div, { y: 500 }); - lazyTranslator.observe(textNode, translator); + nodesIntersectionObserver.observe(textNode, translator); await awaitTranslation(); // does not translate because the node does not intersect the container @@ -171,7 +171,7 @@ test('Triggers callback only after node intersects viewport', async () => { }); test('Does not triggers callback for node that does not intersect viewport after scrolling', async () => { - const lazyTranslator = new NodesIntersectionObserver(); + const nodesIntersectionObserver = new NodesIntersectionObserver(); const div = document.createElement('div'); div.textContent = 'Hello world!'; document.body.appendChild(div); @@ -184,7 +184,7 @@ test('Does not triggers callback for node that does not intersect viewport after // node is outside the viewport and does not intersect the container changeElementPosition(div, { y: 500 }); - lazyTranslator.observe(textNode, translator); + nodesIntersectionObserver.observe(textNode, translator); await awaitTranslation(); // does not translate because the element does not intersect the container From 433e8616cba4932e348cd6efacbc29cba75e1e0b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 25 Jun 2025 18:19:03 +0200 Subject: [PATCH 290/313] test: add, wrap test to block --- src/__tests__/TranslationDispatcher.test.ts | 117 ++++++++++++-------- 1 file changed, 70 insertions(+), 47 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 2dd9178..9077084 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -20,57 +20,80 @@ beforeEach(() => { const isTranslatableNode = () => true; -test('In lazy-translation mode a non-intersecting node translates immediately', async () => { - const translationDispatcher = new TranslationDispatcher({ - filter: isTranslatableNode, - nodesTranslator: new DOMNodesTranslator(translator), - nodesIntersectionObserver: new NodesIntersectionObserver(), +describe('Translate node in lazy-translation mode', () => { + test('translates non-intersecting node immediately', async () => { + const translationDispatcher = new TranslationDispatcher({ + filter: isTranslatableNode, + nodesTranslator: new DOMNodesTranslator(translator), + nodesIntersectionObserver: new NodesIntersectionObserver(), + }); + + // OPTION node is not intersectable; it cannot be translated later + const select = document.createElement('select'); + const option = document.createElement('option'); + option.textContent = 'Hello, world!'; + select.appendChild(option); + document.body.appendChild(select); + + // element is outside the viewport + // IntersectionObserver should not invoke the callback until the node appears in the viewport + mockBoundingClientRect(option, { width: 100, height: 100, x: 0, y: 300 }); + mockBoundingClientRect(document.body, { width: 200, height: 200, x: 0, y: 0 }); + + // the element is translated regardless of viewport intersection + translationDispatcher.translateNode(select); + await awaitTranslation(); + expect(option.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); }); - // OPTION node is not intersectable; it cannot be translated later - const select = document.createElement('select'); - const option = document.createElement('option'); - option.textContent = 'Hello, world!'; - select.appendChild(option); - document.body.appendChild(select); - - // element is outside the viewport - // IntersectionObserver should not invoke the callback until the node appears in the viewport - mockBoundingClientRect(option, { width: 50, height: 100, x: 0, y: 300 }); - mockBoundingClientRect(document.body, { width: 100, height: 200, x: 0, y: 0 }); - - // the element is translated regardless of viewport intersection - translationDispatcher.translateNode(select); - await awaitTranslation(); - expect(option.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); -}); - -test('In lazy-translation mode node not attached to document.body translate immediately', async () => { - const translationDispatcher = new TranslationDispatcher({ - filter: isTranslatableNode, - nodesTranslator: new DOMNodesTranslator(translator), - nodesIntersectionObserver: new NodesIntersectionObserver(), + test('translates node not attached to document.body immediately', async () => { + const translationDispatcher = new TranslationDispatcher({ + filter: isTranslatableNode, + nodesTranslator: new DOMNodesTranslator(translator), + nodesIntersectionObserver: new NodesIntersectionObserver(), + }); + + // div not attached to body, it cannot be translated later + const div = document.createElement('div'); + div.textContent = 'hello'; + + // element is outside the viewport + mockBoundingClientRect(div, { width: 100, height: 100, x: 0, y: 300 }); + mockBoundingClientRect(document.body, { width: 200, height: 200, x: 0, y: 0 }); + + // the element is translated regardless of viewport intersection + translationDispatcher.translateNode(div); + await awaitTranslation(); + expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); }); - // shadow element is not attached to document.body, so it is not intersectable and cannot be translated later - const host = document.createElement('div'); - const shadowRoot = host.attachShadow({ mode: 'open' }); - const div = document.createElement('div'); - const text = 'Hello world'; - div.textContent = text; - - shadowRoot.appendChild(div); - document.body.appendChild(host); - - // element is outside the viewport - // IntersectionObserver should not invoke the callback until the node appears in the viewport - mockBoundingClientRect(div, { width: 50, height: 100, x: 0, y: 300 }); - mockBoundingClientRect(document.body, { width: 100, height: 200, x: 0, y: 0 }); - - // the element is translated regardless of viewport intersection - translationDispatcher.translateNode(div); - await awaitTranslation(); - expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + test('translates node inside shadow DOM immediately', async () => { + const translationDispatcher = new TranslationDispatcher({ + filter: isTranslatableNode, + nodesTranslator: new DOMNodesTranslator(translator), + nodesIntersectionObserver: new NodesIntersectionObserver(), + }); + + // The element nested inside a shadow element is not directly attached to document.body + // so it is not intersectable and cannot be translated later + const host = document.createElement('div'); + const shadowRoot = host.attachShadow({ mode: 'open' }); + const div = document.createElement('div'); + const text = 'Hello world'; + div.textContent = text; + + shadowRoot.appendChild(div); + document.body.appendChild(host); + + // element is outside the viewport + mockBoundingClientRect(div, { width: 100, height: 100, x: 0, y: 300 }); + mockBoundingClientRect(document.body, { width: 200, height: 200, x: 0, y: 0 }); + + // the element is translated regardless of viewport intersection + translationDispatcher.translateNode(div); + await awaitTranslation(); + expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + }); }); test('Translates and restores the element and its child elements', async () => { From 1259ce5125c9bda85745609d8649b40f57595973 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 25 Jun 2025 18:30:35 +0200 Subject: [PATCH 291/313] test: replace function --- src/__tests__/TranslationDispatcher.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 9077084..f40baee 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -1,7 +1,6 @@ import { DOMNodesTranslator } from '../DOMNodesTranslator'; import { NodesIntersectionObserver } from '../lib/NodesIntersectionObserver'; import { TranslationDispatcher } from '../TranslationDispatcher'; -import { configureTranslatableNodePredicate } from '../utils/nodes'; import { awaitTranslation, mockBoundingClientRect, @@ -149,9 +148,10 @@ test('Callback is called after the node is restored', async () => { }); test('Does not translate ignored node', async () => { - const filter = configureTranslatableNodePredicate({ - ignoredSelectors: ['title'], - }); + const filter = (node: Node) => { + if (node.nodeName === 'title') return false; + return true; + }; const translationDispatcher = new TranslationDispatcher({ filter, nodesTranslator: new DOMNodesTranslator(translator), From 24a4f5e47f4fc798186ad610223d6bf6e02af953 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 25 Jun 2025 18:33:56 +0200 Subject: [PATCH 292/313] chore: remove --- src/lib/NodesIntersectionObserver.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index 7106042..218c8a9 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -64,7 +64,6 @@ describe('Trigger callback for nodes in viewport', () => { test('triggers for attribute', async () => { const nodesIntersectionObserver = new NodesIntersectionObserver(); const attr = document.createAttribute('title'); - attr.nodeValue = 'title text'; nodesIntersectionObserver.observe(attr, callback); await awaitTranslation(); From a6573b29439a0251a0af89bc6034a46e8b2da552 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 25 Jun 2025 23:10:51 +0200 Subject: [PATCH 293/313] refactor: rename, update --- src/lib/NodesIntersectionObserver.test.ts | 52 ++++++++++------------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index 218c8a9..c7806f3 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -12,7 +12,7 @@ const translator = vi.fn().mockImplementation(async (node: Node) => { node.textContent = TRANSLATION_SYMBOL + node.textContent; }); -const changeElementPosition = ( +const resetElementPosition = ( node: HTMLElement, position?: { width?: number; @@ -34,7 +34,7 @@ const changeElementPosition = ( }; beforeEach(() => { - changeElementPosition(document.body); + resetElementPosition(document.body, { width: 1280, height: 960 }); document.body.textContent = ''; vi.clearAllMocks(); }); @@ -43,29 +43,29 @@ describe('Trigger callback for nodes in viewport', () => { const callback = vi.fn(); test('triggers for element', async () => { - const nodesIntersectionObserver = new NodesIntersectionObserver(); + const intersectionObserver = new NodesIntersectionObserver(); const div = document.createElement('div'); - nodesIntersectionObserver.observe(div, callback); + intersectionObserver.observe(div, callback); await awaitTranslation(); // mock was called for element expect(callback.mock.calls).toEqual([[div]]); }); test('triggers for node', async () => { - const nodesIntersectionObserver = new NodesIntersectionObserver(); + const intersectionObserver = new NodesIntersectionObserver(); const node = new Text(); - nodesIntersectionObserver.observe(node, callback); + intersectionObserver.observe(node, callback); await awaitTranslation(); expect(callback.mock.calls).toEqual([[node]]); }); test('triggers for attribute', async () => { - const nodesIntersectionObserver = new NodesIntersectionObserver(); + const intersectionObserver = new NodesIntersectionObserver(); const attr = document.createAttribute('title'); - nodesIntersectionObserver.observe(attr, callback); + intersectionObserver.observe(attr, callback); await awaitTranslation(); expect(callback.mock.calls).toEqual([[attr]]); @@ -76,9 +76,9 @@ test('Triggers callback for node in viewport', async () => { const textNode = new Text('Hello, World!'); document.body.appendChild(textNode); - const nodesIntersectionObserver = new NodesIntersectionObserver(); + const intersectionObserver = new NodesIntersectionObserver(); - nodesIntersectionObserver.observe(textNode, translator); + intersectionObserver.observe(textNode, translator); await awaitTranslation(); // The mock function was called once @@ -87,7 +87,7 @@ test('Triggers callback for node in viewport', async () => { }); test('Triggers callback for a node only when it becomes intersectable', async () => { - const nodesIntersectionObserver = new NodesIntersectionObserver(); + const intersectionObserver = new NodesIntersectionObserver(); // node with display = 'none' is not intersectable // node with visibility: 'hidden' is considered intersectable, so use display: 'none' instead @@ -98,7 +98,7 @@ test('Triggers callback for a node only when it becomes intersectable', async () const textNode = div.childNodes[0]; - nodesIntersectionObserver.observe(textNode, translator); + intersectionObserver.observe(textNode, translator); await awaitTranslation(); expect(translator.mock.calls).toEqual([]); @@ -113,7 +113,7 @@ test('Triggers callback for a node only when it becomes intersectable', async () }); test('Does not trigger callback after node is detached', async () => { - const nodesIntersectionObserver = new NodesIntersectionObserver(); + const intersectionObserver = new NodesIntersectionObserver(); // node with display: none is not intersectable const div = document.createElement('div'); @@ -123,7 +123,7 @@ test('Does not trigger callback after node is detached', async () => { const textNode = div.childNodes[0]; - nodesIntersectionObserver.observe(textNode, translator); + intersectionObserver.observe(textNode, translator); await awaitTranslation(); // does not translate because node is not visible @@ -131,7 +131,7 @@ test('Does not trigger callback after node is detached', async () => { expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // node is detached - nodesIntersectionObserver.unobserve(textNode); + intersectionObserver.unobserve(textNode); // becomes visible and intersectable, but still does not translate after being detached div.style.display = 'block'; @@ -141,20 +141,17 @@ test('Does not trigger callback after node is detached', async () => { }); test('Triggers callback only after node intersects viewport', async () => { - const nodesIntersectionObserver = new NodesIntersectionObserver(); + const intersectionObserver = new NodesIntersectionObserver(); const div = document.createElement('div'); div.textContent = 'Hello world!'; document.body.appendChild(div); const textNode = div.childNodes[0]; - // coordinates: x=0, y=0 - changeElementPosition(document.body, { width: 300, height: 300 }); - // element is outside the viewport and does not intersect the container - changeElementPosition(div, { y: 500 }); + resetElementPosition(div, { y: -1000 }); - nodesIntersectionObserver.observe(textNode, translator); + intersectionObserver.observe(textNode, translator); await awaitTranslation(); // does not translate because the node does not intersect the container @@ -162,7 +159,7 @@ test('Triggers callback only after node intersects viewport', async () => { expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // change coordinates, the node is now inside the viewport (coordinates: x=0, y=0) - changeElementPosition(div); + resetElementPosition(div); await awaitTranslation(); expect(translator.mock.calls).toEqual([[textNode]]); @@ -170,20 +167,17 @@ test('Triggers callback only after node intersects viewport', async () => { }); test('Does not triggers callback for node that does not intersect viewport after scrolling', async () => { - const nodesIntersectionObserver = new NodesIntersectionObserver(); + const intersectionObserver = new NodesIntersectionObserver(); const div = document.createElement('div'); div.textContent = 'Hello world!'; document.body.appendChild(div); const textNode = div.childNodes[0]; - // coordinates: x=0, y=0 - changeElementPosition(document.body, { width: 300, height: 300 }); - // node is outside the viewport and does not intersect the container - changeElementPosition(div, { y: 500 }); + resetElementPosition(div, { y: -1000 }); - nodesIntersectionObserver.observe(textNode, translator); + intersectionObserver.observe(textNode, translator); await awaitTranslation(); // does not translate because the element does not intersect the container @@ -191,7 +185,7 @@ test('Does not triggers callback for node that does not intersect viewport after expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); // change coordinates, the node is still outside the viewport - changeElementPosition(div, { y: 330 }); + resetElementPosition(div, { y: -1500 }); await awaitTranslation(); From e4d7bf20d77e09f828b31994eb6df88a23dcc4d6 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 26 Jun 2025 00:17:46 +0200 Subject: [PATCH 294/313] refactor: update element position --- src/__tests__/TranslationDispatcher.test.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index f40baee..47fe1a5 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -13,7 +13,7 @@ require('intersection-observer'); beforeEach(() => { document.body.innerHTML = ''; - mockBoundingClientRect(document.body, { width: 100, height: 100, x: 0, y: 0 }); + mockBoundingClientRect(document.body, { width: 1280, height: 960, x: 0, y: 0 }); vi.clearAllMocks(); }); @@ -36,8 +36,7 @@ describe('Translate node in lazy-translation mode', () => { // element is outside the viewport // IntersectionObserver should not invoke the callback until the node appears in the viewport - mockBoundingClientRect(option, { width: 100, height: 100, x: 0, y: 300 }); - mockBoundingClientRect(document.body, { width: 200, height: 200, x: 0, y: 0 }); + mockBoundingClientRect(option, { width: 100, height: 100, x: 0, y: -1000 }); // the element is translated regardless of viewport intersection translationDispatcher.translateNode(select); @@ -57,8 +56,7 @@ describe('Translate node in lazy-translation mode', () => { div.textContent = 'hello'; // element is outside the viewport - mockBoundingClientRect(div, { width: 100, height: 100, x: 0, y: 300 }); - mockBoundingClientRect(document.body, { width: 200, height: 200, x: 0, y: 0 }); + mockBoundingClientRect(div, { width: 100, height: 100, x: 0, y: -1000 }); // the element is translated regardless of viewport intersection translationDispatcher.translateNode(div); @@ -85,8 +83,7 @@ describe('Translate node in lazy-translation mode', () => { document.body.appendChild(host); // element is outside the viewport - mockBoundingClientRect(div, { width: 100, height: 100, x: 0, y: 300 }); - mockBoundingClientRect(document.body, { width: 200, height: 200, x: 0, y: 0 }); + mockBoundingClientRect(div, { width: 100, height: 100, x: 0, y: -1000 }); // the element is translated regardless of viewport intersection translationDispatcher.translateNode(div); From f817d9cd366827b4bcb71a0b1eaf7176c355e059 Mon Sep 17 00:00:00 2001 From: katsyuta <134226617+katsyuta@users.noreply.github.com> Date: Thu, 26 Jun 2025 00:18:29 +0200 Subject: [PATCH 295/313] Update src/__tests__/TranslationDispatcher.test.ts Co-authored-by: Robert Vitonsky --- src/__tests__/TranslationDispatcher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 47fe1a5..626659f 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -20,7 +20,7 @@ beforeEach(() => { const isTranslatableNode = () => true; describe('Translate node in lazy-translation mode', () => { - test('translates non-intersecting node immediately', async () => { + test('immediately translates non-intersecting node', async () => { const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodesTranslator: new DOMNodesTranslator(translator), From 38d9de6a52b2c32ec13dc482fd5269ce66624d97 Mon Sep 17 00:00:00 2001 From: katsyuta <134226617+katsyuta@users.noreply.github.com> Date: Thu, 26 Jun 2025 00:18:43 +0200 Subject: [PATCH 296/313] Update src/__tests__/TranslationDispatcher.test.ts Co-authored-by: Robert Vitonsky --- src/__tests__/TranslationDispatcher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 626659f..ee64e1e 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -44,7 +44,7 @@ describe('Translate node in lazy-translation mode', () => { expect(option.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); }); - test('translates node not attached to document.body immediately', async () => { + test('immediately translates node not attached to document.body', async () => { const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodesTranslator: new DOMNodesTranslator(translator), From 94b7cea5e4082ce7e6d1d8f33732b375ae0e20a8 Mon Sep 17 00:00:00 2001 From: katsyuta <134226617+katsyuta@users.noreply.github.com> Date: Thu, 26 Jun 2025 00:18:56 +0200 Subject: [PATCH 297/313] Update src/__tests__/TranslationDispatcher.test.ts Co-authored-by: Robert Vitonsky --- src/__tests__/TranslationDispatcher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index ee64e1e..3844bc0 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -64,7 +64,7 @@ describe('Translate node in lazy-translation mode', () => { expect(div.textContent).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); }); - test('translates node inside shadow DOM immediately', async () => { + test('immediately translates node inside shadow DOM', async () => { const translationDispatcher = new TranslationDispatcher({ filter: isTranslatableNode, nodesTranslator: new DOMNodesTranslator(translator), From 916ebaa814ab8749c64e5927674459c904be061a Mon Sep 17 00:00:00 2001 From: katsyuta <134226617+katsyuta@users.noreply.github.com> Date: Thu, 26 Jun 2025 00:19:07 +0200 Subject: [PATCH 298/313] Update src/__tests__/TranslationDispatcher.test.ts Co-authored-by: Robert Vitonsky --- src/__tests__/TranslationDispatcher.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/__tests__/TranslationDispatcher.test.ts b/src/__tests__/TranslationDispatcher.test.ts index 3844bc0..3385589 100644 --- a/src/__tests__/TranslationDispatcher.test.ts +++ b/src/__tests__/TranslationDispatcher.test.ts @@ -145,10 +145,7 @@ test('Callback is called after the node is restored', async () => { }); test('Does not translate ignored node', async () => { - const filter = (node: Node) => { - if (node.nodeName === 'title') return false; - return true; - }; + const filter = (node: Node) => node.nodeName !== 'title'; const translationDispatcher = new TranslationDispatcher({ filter, nodesTranslator: new DOMNodesTranslator(translator), From 07a5710377e6cbeb5e984fa6af715be0f8b21a70 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 26 Jun 2025 00:22:49 +0200 Subject: [PATCH 299/313] refactor: improve default value --- src/lib/NodesIntersectionObserver.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index c7806f3..154761e 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -14,20 +14,24 @@ const translator = vi.fn().mockImplementation(async (node: Node) => { const resetElementPosition = ( node: HTMLElement, - position?: { + { + width = 100, + height = 100, + x = 0, + y = 0, + }: { width?: number; height?: number; x?: number; y?: number; - }, + } = {}, ) => { mockBoundingClientRect(node, { - width: position?.width ?? 100, - height: position?.height ?? 100, - x: position?.x ?? 0, - y: position?.y ?? 0, + width, + height, + x, + y, }); - // simulate a scroll event; the polyfill listens for the "scroll" event on the document // The polyfill starts recalculating element positions only after the event document.dispatchEvent(new Event('scroll')); From fe5dd8170da062ef1a102a5abb27643149c96e16 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 26 Jun 2025 13:31:50 +0200 Subject: [PATCH 300/313] test: add wrapper for wait calls --- src/lib/NodesIntersectionObserver.test.ts | 113 ++++++++++------------ 1 file changed, 52 insertions(+), 61 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index 154761e..1da7443 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -1,17 +1,10 @@ -import { - awaitTranslation, - mockBoundingClientRect, - startsWithRegex, - TRANSLATION_SYMBOL, -} from '../__tests__/utils'; +import { Mock } from 'vitest'; + +import { mockBoundingClientRect } from '../__tests__/utils'; import { NodesIntersectionObserver } from './NodesIntersectionObserver'; require('intersection-observer'); -const translator = vi.fn().mockImplementation(async (node: Node) => { - node.textContent = TRANSLATION_SYMBOL + node.textContent; -}); - const resetElementPosition = ( node: HTMLElement, { @@ -37,6 +30,26 @@ const resetElementPosition = ( document.dispatchEvent(new Event('scroll')); }; +const callback = vi.fn(); + +const waitMockCall = (callback: Mock, timeout?: number) => { + return new Promise((resolve, reject) => { + const start = Date.now(); + + const interval = setInterval(() => { + if (callback.mock.calls.length == 1) { + clearInterval(interval); + resolve(); + } + + if (timeout && Date.now() - start > timeout) { + clearInterval(interval); + reject('Timeout expired'); + } + }, 100); + }); +}; + beforeEach(() => { resetElementPosition(document.body, { width: 1280, height: 960 }); document.body.textContent = ''; @@ -44,52 +57,36 @@ beforeEach(() => { }); describe('Trigger callback for nodes in viewport', () => { - const callback = vi.fn(); - test('triggers for element', async () => { const intersectionObserver = new NodesIntersectionObserver(); const div = document.createElement('div'); intersectionObserver.observe(div, callback); - await awaitTranslation(); + await waitMockCall(callback); - // mock was called for element + // callback was called for element expect(callback.mock.calls).toEqual([[div]]); }); test('triggers for node', async () => { const intersectionObserver = new NodesIntersectionObserver(); - const node = new Text(); + const textNode = new Text('Hello, World!'); - intersectionObserver.observe(node, callback); - await awaitTranslation(); + intersectionObserver.observe(textNode, callback); + await waitMockCall(callback); - expect(callback.mock.calls).toEqual([[node]]); + expect(callback.mock.calls).toEqual([[textNode]]); }); test('triggers for attribute', async () => { const intersectionObserver = new NodesIntersectionObserver(); const attr = document.createAttribute('title'); intersectionObserver.observe(attr, callback); - await awaitTranslation(); + await waitMockCall(callback); expect(callback.mock.calls).toEqual([[attr]]); }); }); -test('Triggers callback for node in viewport', async () => { - const textNode = new Text('Hello, World!'); - document.body.appendChild(textNode); - - const intersectionObserver = new NodesIntersectionObserver(); - - intersectionObserver.observe(textNode, translator); - await awaitTranslation(); - - // The mock function was called once - expect(translator.mock.calls).toEqual([[textNode]]); - expect(textNode.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); -}); - test('Triggers callback for a node only when it becomes intersectable', async () => { const intersectionObserver = new NodesIntersectionObserver(); @@ -102,18 +99,16 @@ test('Triggers callback for a node only when it becomes intersectable', async () const textNode = div.childNodes[0]; - intersectionObserver.observe(textNode, translator); - await awaitTranslation(); + intersectionObserver.observe(textNode, callback); + await expect(waitMockCall(callback, 200)).rejects.toThrow(); - expect(translator.mock.calls).toEqual([]); - expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(callback.mock.calls).toEqual([]); // the node becomes visible and is translated div.style.display = 'block'; - await awaitTranslation(); + await waitMockCall(callback); - expect(translator.mock.calls).toEqual([[textNode]]); - expect(textNode.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(callback.mock.calls).toEqual([[textNode]]); }); test('Does not trigger callback after node is detached', async () => { @@ -127,21 +122,20 @@ test('Does not trigger callback after node is detached', async () => { const textNode = div.childNodes[0]; - intersectionObserver.observe(textNode, translator); - await awaitTranslation(); + intersectionObserver.observe(textNode, callback); + await expect(waitMockCall(callback, 200)).rejects.toThrow(); // does not translate because node is not visible - expect(translator.mock.calls).toEqual([]); - expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(callback.mock.calls).toEqual([]); // node is detached intersectionObserver.unobserve(textNode); // becomes visible and intersectable, but still does not translate after being detached div.style.display = 'block'; - await awaitTranslation(); - expect(translator.mock.calls).toEqual([]); - expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + await expect(waitMockCall(callback, 200)).rejects.toThrow(); + + expect(callback.mock.calls).toEqual([]); }); test('Triggers callback only after node intersects viewport', async () => { @@ -155,19 +149,18 @@ test('Triggers callback only after node intersects viewport', async () => { // element is outside the viewport and does not intersect the container resetElementPosition(div, { y: -1000 }); - intersectionObserver.observe(textNode, translator); - await awaitTranslation(); + intersectionObserver.observe(textNode, callback); + await expect(waitMockCall(callback, 200)).rejects.toThrow(); // does not translate because the node does not intersect the container - expect(translator.mock.calls).toEqual([]); - expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(callback.mock.calls).toEqual([]); // change coordinates, the node is now inside the viewport (coordinates: x=0, y=0) resetElementPosition(div); - await awaitTranslation(); - expect(translator.mock.calls).toEqual([[textNode]]); - expect(textNode.nodeValue).toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + await waitMockCall(callback); + + expect(callback.mock.calls).toEqual([[textNode]]); }); test('Does not triggers callback for node that does not intersect viewport after scrolling', async () => { @@ -181,19 +174,17 @@ test('Does not triggers callback for node that does not intersect viewport after // node is outside the viewport and does not intersect the container resetElementPosition(div, { y: -1000 }); - intersectionObserver.observe(textNode, translator); - await awaitTranslation(); + intersectionObserver.observe(textNode, callback); + await expect(waitMockCall(callback, 200)).rejects.toThrow(); // does not translate because the element does not intersect the container - expect(translator.mock.calls).toEqual([]); - expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(callback.mock.calls).toEqual([]); // change coordinates, the node is still outside the viewport resetElementPosition(div, { y: -1500 }); - await awaitTranslation(); + await expect(waitMockCall(callback, 200)).rejects.toThrow(); // still not translated - expect(translator.mock.calls).toEqual([]); - expect(textNode.nodeValue).not.toMatch(startsWithRegex(TRANSLATION_SYMBOL)); + expect(callback.mock.calls).toEqual([]); }); From 2a3935bca43b022a50ce960188d42d04ffbda020 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 26 Jun 2025 13:41:46 +0200 Subject: [PATCH 301/313] chore: fix typo, remove variable --- src/lib/NodesIntersectionObserver.test.ts | 39 ++++++++++------------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index 1da7443..e1a9509 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -64,7 +64,6 @@ describe('Trigger callback for nodes in viewport', () => { intersectionObserver.observe(div, callback); await waitMockCall(callback); - // callback was called for element expect(callback.mock.calls).toEqual([[div]]); }); test('triggers for node', async () => { @@ -97,18 +96,16 @@ test('Triggers callback for a node only when it becomes intersectable', async () div.style.display = 'none'; document.body.appendChild(div); - const textNode = div.childNodes[0]; - - intersectionObserver.observe(textNode, callback); + intersectionObserver.observe(div, callback); await expect(waitMockCall(callback, 200)).rejects.toThrow(); expect(callback.mock.calls).toEqual([]); - // the node becomes visible and is translated + // the node becomes visible and the callback is called div.style.display = 'block'; await waitMockCall(callback); - expect(callback.mock.calls).toEqual([[textNode]]); + expect(callback.mock.calls).toEqual([[div]]); }); test('Does not trigger callback after node is detached', async () => { @@ -120,18 +117,16 @@ test('Does not trigger callback after node is detached', async () => { div.style.display = 'none'; document.body.appendChild(div); - const textNode = div.childNodes[0]; - - intersectionObserver.observe(textNode, callback); + intersectionObserver.observe(div, callback); await expect(waitMockCall(callback, 200)).rejects.toThrow(); - // does not translate because node is not visible + // does not call the callback because the node is not visible expect(callback.mock.calls).toEqual([]); // node is detached - intersectionObserver.unobserve(textNode); + intersectionObserver.unobserve(div); - // becomes visible and intersectable, but still does not translate after being detached + // becomes visible and intersectable, but still doesn't call the callback after being detached div.style.display = 'block'; await expect(waitMockCall(callback, 200)).rejects.toThrow(); @@ -140,44 +135,42 @@ test('Does not trigger callback after node is detached', async () => { test('Triggers callback only after node intersects viewport', async () => { const intersectionObserver = new NodesIntersectionObserver(); + const div = document.createElement('div'); div.textContent = 'Hello world!'; document.body.appendChild(div); - const textNode = div.childNodes[0]; - // element is outside the viewport and does not intersect the container resetElementPosition(div, { y: -1000 }); - intersectionObserver.observe(textNode, callback); + intersectionObserver.observe(div, callback); await expect(waitMockCall(callback, 200)).rejects.toThrow(); - // does not translate because the node does not intersect the container + // the callback is not called because the node does not intersect the container expect(callback.mock.calls).toEqual([]); - // change coordinates, the node is now inside the viewport (coordinates: x=0, y=0) + // change coordinates, the node is now inside the viewport resetElementPosition(div); await waitMockCall(callback); - expect(callback.mock.calls).toEqual([[textNode]]); + expect(callback.mock.calls).toEqual([[div]]); }); test('Does not triggers callback for node that does not intersect viewport after scrolling', async () => { const intersectionObserver = new NodesIntersectionObserver(); + const div = document.createElement('div'); div.textContent = 'Hello world!'; document.body.appendChild(div); - const textNode = div.childNodes[0]; - // node is outside the viewport and does not intersect the container resetElementPosition(div, { y: -1000 }); - intersectionObserver.observe(textNode, callback); + intersectionObserver.observe(div, callback); await expect(waitMockCall(callback, 200)).rejects.toThrow(); - // does not translate because the element does not intersect the container + // the callback is not called because the element does not intersect the container expect(callback.mock.calls).toEqual([]); // change coordinates, the node is still outside the viewport @@ -185,6 +178,6 @@ test('Does not triggers callback for node that does not intersect viewport after await expect(waitMockCall(callback, 200)).rejects.toThrow(); - // still not translated + // still not called expect(callback.mock.calls).toEqual([]); }); From 3bc4f630ed0109f12a53b01c9c555211bc55b478 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 26 Jun 2025 13:58:29 +0200 Subject: [PATCH 302/313] chore: update timeout value --- src/lib/NodesIntersectionObserver.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index e1a9509..e7fca2e 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -32,7 +32,7 @@ const resetElementPosition = ( const callback = vi.fn(); -const waitMockCall = (callback: Mock, timeout?: number) => { +const waitMockCall = (callback: Mock, timeout = 200) => { return new Promise((resolve, reject) => { const start = Date.now(); @@ -46,7 +46,7 @@ const waitMockCall = (callback: Mock, timeout?: number) => { clearInterval(interval); reject('Timeout expired'); } - }, 100); + }, 10); }); }; @@ -97,7 +97,7 @@ test('Triggers callback for a node only when it becomes intersectable', async () document.body.appendChild(div); intersectionObserver.observe(div, callback); - await expect(waitMockCall(callback, 200)).rejects.toThrow(); + await expect(waitMockCall(callback)).rejects.toThrow(); expect(callback.mock.calls).toEqual([]); @@ -118,7 +118,7 @@ test('Does not trigger callback after node is detached', async () => { document.body.appendChild(div); intersectionObserver.observe(div, callback); - await expect(waitMockCall(callback, 200)).rejects.toThrow(); + await expect(waitMockCall(callback)).rejects.toThrow(); // does not call the callback because the node is not visible expect(callback.mock.calls).toEqual([]); @@ -128,7 +128,7 @@ test('Does not trigger callback after node is detached', async () => { // becomes visible and intersectable, but still doesn't call the callback after being detached div.style.display = 'block'; - await expect(waitMockCall(callback, 200)).rejects.toThrow(); + await expect(waitMockCall(callback)).rejects.toThrow(); expect(callback.mock.calls).toEqual([]); }); @@ -144,7 +144,7 @@ test('Triggers callback only after node intersects viewport', async () => { resetElementPosition(div, { y: -1000 }); intersectionObserver.observe(div, callback); - await expect(waitMockCall(callback, 200)).rejects.toThrow(); + await expect(waitMockCall(callback)).rejects.toThrow(); // the callback is not called because the node does not intersect the container expect(callback.mock.calls).toEqual([]); @@ -168,7 +168,7 @@ test('Does not triggers callback for node that does not intersect viewport after resetElementPosition(div, { y: -1000 }); intersectionObserver.observe(div, callback); - await expect(waitMockCall(callback, 200)).rejects.toThrow(); + await expect(waitMockCall(callback)).rejects.toThrow(); // the callback is not called because the element does not intersect the container expect(callback.mock.calls).toEqual([]); @@ -176,7 +176,7 @@ test('Does not triggers callback for node that does not intersect viewport after // change coordinates, the node is still outside the viewport resetElementPosition(div, { y: -1500 }); - await expect(waitMockCall(callback, 200)).rejects.toThrow(); + await expect(waitMockCall(callback)).rejects.toThrow(); // still not called expect(callback.mock.calls).toEqual([]); From 36bb89d01b17eda7288f88facc152316776e79fe Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 26 Jun 2025 14:04:44 +0200 Subject: [PATCH 303/313] chore: remove variable from check --- src/lib/NodesIntersectionObserver.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index e7fca2e..ed1268c 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -30,8 +30,6 @@ const resetElementPosition = ( document.dispatchEvent(new Event('scroll')); }; -const callback = vi.fn(); - const waitMockCall = (callback: Mock, timeout = 200) => { return new Promise((resolve, reject) => { const start = Date.now(); @@ -42,7 +40,7 @@ const waitMockCall = (callback: Mock, timeout = 200) => { resolve(); } - if (timeout && Date.now() - start > timeout) { + if (Date.now() - start > timeout) { clearInterval(interval); reject('Timeout expired'); } @@ -50,6 +48,8 @@ const waitMockCall = (callback: Mock, timeout = 200) => { }); }; +const callback = vi.fn(); + beforeEach(() => { resetElementPosition(document.body, { width: 1280, height: 960 }); document.body.textContent = ''; From 1accfe6d89631f21e6e7882e0ce8f625b121234a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 26 Jun 2025 15:08:03 +0200 Subject: [PATCH 304/313] chore: update checks --- src/lib/NodesIntersectionObserver.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index ed1268c..9039d55 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -31,18 +31,23 @@ const resetElementPosition = ( }; const waitMockCall = (callback: Mock, timeout = 200) => { + const initialCallCount = callback.mock.calls.length; + return new Promise((resolve, reject) => { const start = Date.now(); const interval = setInterval(() => { - if (callback.mock.calls.length == 1) { + if ( + callback.mock.calls.length == 1 || + callback.mock.calls.length > initialCallCount + ) { clearInterval(interval); resolve(); } if (Date.now() - start > timeout) { clearInterval(interval); - reject('Timeout expired'); + reject(new Error('Timeout expired')); } }, 10); }); @@ -99,6 +104,7 @@ test('Triggers callback for a node only when it becomes intersectable', async () intersectionObserver.observe(div, callback); await expect(waitMockCall(callback)).rejects.toThrow(); + // does not call the callback because the node is not visible expect(callback.mock.calls).toEqual([]); // the node becomes visible and the callback is called From 54b38bb92a8e5e1afc059601835f7c038854f9a8 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 26 Jun 2025 15:30:06 +0200 Subject: [PATCH 305/313] test: add expect --- src/lib/NodesIntersectionObserver.test.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index 9039d55..9f26701 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -37,10 +37,7 @@ const waitMockCall = (callback: Mock, timeout = 200) => { const start = Date.now(); const interval = setInterval(() => { - if ( - callback.mock.calls.length == 1 || - callback.mock.calls.length > initialCallCount - ) { + if (callback.mock.calls.length > initialCallCount) { clearInterval(interval); resolve(); } @@ -67,7 +64,7 @@ describe('Trigger callback for nodes in viewport', () => { const div = document.createElement('div'); intersectionObserver.observe(div, callback); - await waitMockCall(callback); + await expect(waitMockCall(callback)).rejects.toThrow(); expect(callback.mock.calls).toEqual([[div]]); }); @@ -76,7 +73,7 @@ describe('Trigger callback for nodes in viewport', () => { const textNode = new Text('Hello, World!'); intersectionObserver.observe(textNode, callback); - await waitMockCall(callback); + await expect(waitMockCall(callback)).rejects.toThrow(); expect(callback.mock.calls).toEqual([[textNode]]); }); @@ -85,7 +82,7 @@ describe('Trigger callback for nodes in viewport', () => { const attr = document.createAttribute('title'); intersectionObserver.observe(attr, callback); - await waitMockCall(callback); + await expect(waitMockCall(callback)).rejects.toThrow(); expect(callback.mock.calls).toEqual([[attr]]); }); From 9b280d8c15e4465d7e3d03e0eb3b2918f096497b Mon Sep 17 00:00:00 2001 From: katsyuta <134226617+katsyuta@users.noreply.github.com> Date: Thu, 26 Jun 2025 17:12:53 +0200 Subject: [PATCH 306/313] Update NodesIntersectionObserver.test.ts --- src/lib/NodesIntersectionObserver.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index 9f26701..dc5fdd1 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -59,7 +59,8 @@ beforeEach(() => { }); describe('Trigger callback for nodes in viewport', () => { - test('triggers for element', async () => { +// /// +test('triggers for element', async () => { const intersectionObserver = new NodesIntersectionObserver(); const div = document.createElement('div'); From f5a8c03df92eba120e6180cfe55ac23dc8b2f768 Mon Sep 17 00:00:00 2001 From: katsyuta <134226617+katsyuta@users.noreply.github.com> Date: Thu, 26 Jun 2025 17:30:47 +0200 Subject: [PATCH 307/313] Update DOMNodesTranslator.ts From 42013fceed350b8cfa0d6395674a37e5de41ca34 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 26 Jun 2025 17:54:56 +0200 Subject: [PATCH 308/313] chore: fix typo --- src/lib/NodesIntersectionObserver.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index dc5fdd1..9f26701 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -59,8 +59,7 @@ beforeEach(() => { }); describe('Trigger callback for nodes in viewport', () => { -// /// -test('triggers for element', async () => { + test('triggers for element', async () => { const intersectionObserver = new NodesIntersectionObserver(); const div = document.createElement('div'); From a92779d32435f0179febfda07653c3173f6ebf9a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 26 Jun 2025 18:21:47 +0200 Subject: [PATCH 309/313] test: improve checks --- src/lib/NodesIntersectionObserver.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index 9f26701..4aec10b 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -62,18 +62,20 @@ describe('Trigger callback for nodes in viewport', () => { test('triggers for element', async () => { const intersectionObserver = new NodesIntersectionObserver(); const div = document.createElement('div'); + document.body.appendChild(div); intersectionObserver.observe(div, callback); - await expect(waitMockCall(callback)).rejects.toThrow(); + await waitMockCall(callback); expect(callback.mock.calls).toEqual([[div]]); }); test('triggers for node', async () => { const intersectionObserver = new NodesIntersectionObserver(); const textNode = new Text('Hello, World!'); + document.body.appendChild(textNode); intersectionObserver.observe(textNode, callback); - await expect(waitMockCall(callback)).rejects.toThrow(); + await waitMockCall(callback); expect(callback.mock.calls).toEqual([[textNode]]); }); @@ -81,8 +83,13 @@ describe('Trigger callback for nodes in viewport', () => { const intersectionObserver = new NodesIntersectionObserver(); const attr = document.createAttribute('title'); + // attach attr to DOM + const div = document.createElement('div'); + div.setAttributeNode(attr); + document.body.appendChild(div); + intersectionObserver.observe(attr, callback); - await expect(waitMockCall(callback)).rejects.toThrow(); + await waitMockCall(callback); expect(callback.mock.calls).toEqual([[attr]]); }); From 6a99f477e65646a3bc9ad7f67b1e14f062573ec3 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 27 Jun 2025 11:05:08 +0200 Subject: [PATCH 310/313] refactor: add a return for immediate checking --- src/lib/NodesIntersectionObserver.test.ts | 38 +++++++++-------------- 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index 4aec10b..d3c81b4 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -30,16 +30,16 @@ const resetElementPosition = ( document.dispatchEvent(new Event('scroll')); }; -const waitMockCall = (callback: Mock, timeout = 200) => { +const waitForMockCall = (callback: Mock, timeout = 200) => { const initialCallCount = callback.mock.calls.length; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const start = Date.now(); const interval = setInterval(() => { if (callback.mock.calls.length > initialCallCount) { clearInterval(interval); - resolve(); + resolve(callback.mock.calls); } if (Date.now() - start > timeout) { @@ -65,9 +65,7 @@ describe('Trigger callback for nodes in viewport', () => { document.body.appendChild(div); intersectionObserver.observe(div, callback); - await waitMockCall(callback); - - expect(callback.mock.calls).toEqual([[div]]); + await expect(waitForMockCall(callback)).resolves.toEqual([[div]]); }); test('triggers for node', async () => { const intersectionObserver = new NodesIntersectionObserver(); @@ -75,9 +73,7 @@ describe('Trigger callback for nodes in viewport', () => { document.body.appendChild(textNode); intersectionObserver.observe(textNode, callback); - await waitMockCall(callback); - - expect(callback.mock.calls).toEqual([[textNode]]); + await expect(waitForMockCall(callback)).resolves.toEqual([[textNode]]); }); test('triggers for attribute', async () => { const intersectionObserver = new NodesIntersectionObserver(); @@ -89,9 +85,7 @@ describe('Trigger callback for nodes in viewport', () => { document.body.appendChild(div); intersectionObserver.observe(attr, callback); - await waitMockCall(callback); - - expect(callback.mock.calls).toEqual([[attr]]); + await expect(waitForMockCall(callback)).resolves.toEqual([[attr]]); }); }); @@ -106,16 +100,14 @@ test('Triggers callback for a node only when it becomes intersectable', async () document.body.appendChild(div); intersectionObserver.observe(div, callback); - await expect(waitMockCall(callback)).rejects.toThrow(); + await expect(waitForMockCall(callback)).rejects.toThrow(); // does not call the callback because the node is not visible expect(callback.mock.calls).toEqual([]); // the node becomes visible and the callback is called div.style.display = 'block'; - await waitMockCall(callback); - - expect(callback.mock.calls).toEqual([[div]]); + await expect(waitForMockCall(callback)).resolves.toEqual([[div]]); }); test('Does not trigger callback after node is detached', async () => { @@ -128,7 +120,7 @@ test('Does not trigger callback after node is detached', async () => { document.body.appendChild(div); intersectionObserver.observe(div, callback); - await expect(waitMockCall(callback)).rejects.toThrow(); + await expect(waitForMockCall(callback)).rejects.toThrow(); // does not call the callback because the node is not visible expect(callback.mock.calls).toEqual([]); @@ -138,8 +130,8 @@ test('Does not trigger callback after node is detached', async () => { // becomes visible and intersectable, but still doesn't call the callback after being detached div.style.display = 'block'; - await expect(waitMockCall(callback)).rejects.toThrow(); + await expect(waitForMockCall(callback)).rejects.toThrow(); expect(callback.mock.calls).toEqual([]); }); @@ -154,7 +146,7 @@ test('Triggers callback only after node intersects viewport', async () => { resetElementPosition(div, { y: -1000 }); intersectionObserver.observe(div, callback); - await expect(waitMockCall(callback)).rejects.toThrow(); + await expect(waitForMockCall(callback)).rejects.toThrow(); // the callback is not called because the node does not intersect the container expect(callback.mock.calls).toEqual([]); @@ -162,9 +154,7 @@ test('Triggers callback only after node intersects viewport', async () => { // change coordinates, the node is now inside the viewport resetElementPosition(div); - await waitMockCall(callback); - - expect(callback.mock.calls).toEqual([[div]]); + await expect(waitForMockCall(callback)).resolves.toEqual([[div]]); }); test('Does not triggers callback for node that does not intersect viewport after scrolling', async () => { @@ -178,7 +168,7 @@ test('Does not triggers callback for node that does not intersect viewport after resetElementPosition(div, { y: -1000 }); intersectionObserver.observe(div, callback); - await expect(waitMockCall(callback)).rejects.toThrow(); + await expect(waitForMockCall(callback)).rejects.toThrow(); // the callback is not called because the element does not intersect the container expect(callback.mock.calls).toEqual([]); @@ -186,7 +176,7 @@ test('Does not triggers callback for node that does not intersect viewport after // change coordinates, the node is still outside the viewport resetElementPosition(div, { y: -1500 }); - await expect(waitMockCall(callback)).rejects.toThrow(); + await expect(waitForMockCall(callback)).rejects.toThrow(); // still not called expect(callback.mock.calls).toEqual([]); From 605dfc386d90d39ea2fa2a98fbb087fc256fa431 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 27 Jun 2025 11:07:30 +0200 Subject: [PATCH 311/313] chore: move to utils --- src/__tests__/utils.ts | 47 ++++++++++++++++++++++ src/lib/NodesIntersectionObserver.test.ts | 49 +---------------------- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts index 21d542a..3feb510 100644 --- a/src/__tests__/utils.ts +++ b/src/__tests__/utils.ts @@ -1,3 +1,5 @@ +import { Mock } from 'vitest'; + export const delay = (time: number) => new Promise((res) => setTimeout(res, time)); export const awaitTranslation = () => delay(120); @@ -37,3 +39,48 @@ export const mockBoundingClientRect = ( }), }); }; + +export const resetElementPosition = ( + node: HTMLElement, + { + width = 100, + height = 100, + x = 0, + y = 0, + }: { + width?: number; + height?: number; + x?: number; + y?: number; + } = {}, +) => { + mockBoundingClientRect(node, { + width, + height, + x, + y, + }); + // simulate a scroll event; the polyfill listens for the "scroll" event on the document + // The polyfill starts recalculating element positions only after the event + document.dispatchEvent(new Event('scroll')); +}; + +export const waitForMockCall = (callback: Mock, timeout = 200) => { + const initialCallCount = callback.mock.calls.length; + + return new Promise((resolve, reject) => { + const start = Date.now(); + + const interval = setInterval(() => { + if (callback.mock.calls.length > initialCallCount) { + clearInterval(interval); + resolve(callback.mock.calls); + } + + if (Date.now() - start > timeout) { + clearInterval(interval); + reject(new Error('Timeout expired')); + } + }, 10); + }); +}; diff --git a/src/lib/NodesIntersectionObserver.test.ts b/src/lib/NodesIntersectionObserver.test.ts index d3c81b4..2f448d7 100644 --- a/src/lib/NodesIntersectionObserver.test.ts +++ b/src/lib/NodesIntersectionObserver.test.ts @@ -1,55 +1,8 @@ -import { Mock } from 'vitest'; - -import { mockBoundingClientRect } from '../__tests__/utils'; +import { resetElementPosition, waitForMockCall } from '../__tests__/utils'; import { NodesIntersectionObserver } from './NodesIntersectionObserver'; require('intersection-observer'); -const resetElementPosition = ( - node: HTMLElement, - { - width = 100, - height = 100, - x = 0, - y = 0, - }: { - width?: number; - height?: number; - x?: number; - y?: number; - } = {}, -) => { - mockBoundingClientRect(node, { - width, - height, - x, - y, - }); - // simulate a scroll event; the polyfill listens for the "scroll" event on the document - // The polyfill starts recalculating element positions only after the event - document.dispatchEvent(new Event('scroll')); -}; - -const waitForMockCall = (callback: Mock, timeout = 200) => { - const initialCallCount = callback.mock.calls.length; - - return new Promise((resolve, reject) => { - const start = Date.now(); - - const interval = setInterval(() => { - if (callback.mock.calls.length > initialCallCount) { - clearInterval(interval); - resolve(callback.mock.calls); - } - - if (Date.now() - start > timeout) { - clearInterval(interval); - reject(new Error('Timeout expired')); - } - }, 10); - }); -}; - const callback = vi.fn(); beforeEach(() => { From 58f1a01c7cc57c82c03b3126c4aef0d61eca9513 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 27 Jun 2025 11:25:36 +0200 Subject: [PATCH 312/313] test: add type --- src/__tests__/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts index 3feb510..9ca3d06 100644 --- a/src/__tests__/utils.ts +++ b/src/__tests__/utils.ts @@ -68,7 +68,7 @@ export const resetElementPosition = ( export const waitForMockCall = (callback: Mock, timeout = 200) => { const initialCallCount = callback.mock.calls.length; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const start = Date.now(); const interval = setInterval(() => { From ad17ce73825b579dbfe963591e4a8438d16142f7 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 27 Jun 2025 11:48:06 +0200 Subject: [PATCH 313/313] test: add return --- src/__tests__/utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts index 9ca3d06..3407d48 100644 --- a/src/__tests__/utils.ts +++ b/src/__tests__/utils.ts @@ -75,11 +75,13 @@ export const waitForMockCall = (callback: Mock, timeout = 200) => { if (callback.mock.calls.length > initialCallCount) { clearInterval(interval); resolve(callback.mock.calls); + return; } if (Date.now() - start > timeout) { clearInterval(interval); reject(new Error('Timeout expired')); + return; } }, 10); });