pseudo-localization
Version:
pseudo-localization for internationalization testing
157 lines (140 loc) • 4.51 kB
text/typescript
import {
pseudoLocalizeString,
type PseudoLocalizeStringOptions,
} from './localize.ts';
function isNonEmptyString(str: string | null | undefined): str is string {
return !!str && typeof str === 'string';
}
type StartOptions = PseudoLocalizeStringOptions & {
/**
* Node tags to ignore in pseudo-localization.
*
* @default ['STYLE']
*/
blacklistedNodeNames?: string[];
/**
* The element to pseudo-localize text within.
*
* @default document.body
*/
root?: Node;
};
type StopFn = () => void;
/**
* A container for pseudo-localization of the DOM.
*
* @example
* new PseudoLocalizeDom().start();
*/
export class PseudoLocalizeDom {
/**
* Indicates which elements the pseudo-localization is currently running on.
*/
#enabledOn = new WeakSet<Node>();
/**
* Start pseudo-localizing the DOM.
*
* Returns a stop function to disable pseudo-localization.
*/
start({
strategy = 'accented',
blacklistedNodeNames = ['STYLE'],
root,
}: StartOptions = {}): StopFn {
const rootEl = root ?? document.body;
if (this.#enabledOn.has(rootEl)) {
console.warn(
`Start aborted. pseudo-localization is already enabled on`,
rootEl
);
return () => {};
}
const observerConfig = {
characterData: true,
childList: true,
subtree: true,
};
const textNodesUnder = (node: Node) => {
const walker = document.createTreeWalker(
node,
NodeFilter.SHOW_TEXT,
(node) => {
const isAllWhitespace =
node.nodeValue && !/[^\s]/.test(node.nodeValue);
if (isAllWhitespace) {
return NodeFilter.FILTER_REJECT;
}
const isBlacklistedNode =
node.parentElement &&
blacklistedNodeNames.includes(node.parentElement.nodeName);
if (isBlacklistedNode) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
);
let currNode;
const textNodes = [];
while ((currNode = walker.nextNode())) textNodes.push(currNode);
return textNodes;
};
const pseudoLocalize = (node: Node) => {
const textNodesUnderElement = textNodesUnder(node);
for (const textNode of textNodesUnderElement) {
const nodeValue = textNode.nodeValue;
if (isNonEmptyString(nodeValue)) {
textNode.nodeValue = pseudoLocalizeString(nodeValue, { strategy });
}
}
};
// Pseudo localize the DOM
pseudoLocalize(document.body);
// Start observing the DOM for changes and run
// pseudo localization on any added text nodes
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
// Turn the observer off while performing dom manipulation to prevent
// infinite dom mutation callback loops
observer.disconnect();
// For every node added, recurse down it's subtree and convert
// all children as well
mutation.addedNodes.forEach(pseudoLocalize.bind(this));
observer.observe(document.body, observerConfig);
} else if (mutation.type === 'characterData') {
const nodeValue = mutation.target.nodeValue;
const isBlacklistedNode =
!!mutation.target.parentElement &&
blacklistedNodeNames.includes(
mutation.target.parentElement.nodeName
);
if (isNonEmptyString(nodeValue) && !isBlacklistedNode) {
// Turn the observer off while performing dom manipulation to prevent
// infinite dom mutation callback loops
observer.disconnect();
// The target will always be a text node so it can be converted
// directly
mutation.target.nodeValue = pseudoLocalizeString(nodeValue, {
strategy,
});
observer.observe(document.body, observerConfig);
}
}
}
});
observer.observe(document.body, observerConfig);
this.#enabledOn.add(rootEl);
const stop = () => {
if (!this.#enabledOn.has(rootEl)) {
console.warn(
'Stop aborted. pseudo-localization is not running on',
rootEl
);
return;
}
observer.disconnect();
this.#enabledOn.delete(rootEl);
};
return stop;
}
}