// --------------------------------------------------------------------------- // CONTENT SCRIPT // --------------------------------------------------------------------------- // This script runs on the Webflow Designer/Dashboard. It handles: // 1. Observing DOM changes. // 2. Applying translations to text nodes and attributes. // 3. Managing the "Use latest translations" preference (CDN vs Bundled). // 4. Caching and fallback logic for translation files. import ja from '../locales/ja.json' import zhTw from '../locales/zh-TW.json' import zhCn from '../locales/zh-CN.json' import ko from '../locales/ko.json' import th from '../locales/th.json' import fr from '../locales/fr.json' import it from '../locales/it.json' import { injectDashboardFooter } from './injections' import type { LanguageCode, Dictionary } from '../types' import { LOCALE_CACHE_KEY, EXCLUDED_SELECTORS } from '../constants' import { enableGrabMode, disableGrabMode } from './text-grabber/core' // import { EXCLUDED_SELECTORS } from './exclusion-selectors' // Removed // --------------------------------------------------------------------------- // TYPES // --------------------------------------------------------------------------- type Replacement = { regex: RegExp replacement: string | ((substring: string, ...args: any[]) => string) marker?: string } type Settings = { language: LanguageCode; enabled: boolean; strictMatching: boolean; useCdn: boolean; exclusionSelectors: string[]; grabMode: boolean; // 1. Language Change or Enable/Disablean; } // --------------------------------------------------------------------------- // CONSTANTS // --------------------------------------------------------------------------- const BUNDLED_LANGUAGES: Record, Dictionary> = { ja, 'zh-TW': zhTw, 'zh-CN': zhCn, ko, th, fr, it } const DEFAULT_LANGUAGE: Exclude = 'ja' function getDefaultExclusionSelectors(): string[] { return [...EXCLUDED_SELECTORS] } function resolveExclusionSelectors(value: unknown): string[] { return Array.isArray(value) ? value : getDefaultExclusionSelectors() } function normalizeSettings(raw: Partial): Settings { const resolvedExclusions = resolveExclusionSelectors(raw.exclusionSelectors) return { language: (raw.language as LanguageCode) ?? DEFAULT_LANGUAGE, enabled: typeof raw.enabled === 'boolean' ? raw.enabled : true, strictMatching: typeof raw.strictMatching === 'boolean' ? raw.strictMatching : true, useCdn: typeof raw.useCdn === 'boolean' ? raw.useCdn : true, exclusionSelectors: resolvedExclusions, grabMode: typeof raw.grabMode === 'boolean' ? raw.grabMode : false } } const DEFAULT_SETTINGS: Settings = normalizeSettings({ language: DEFAULT_LANGUAGE, enabled: true, strictMatching: true, useCdn: true, exclusionSelectors: getDefaultExclusionSelectors(), grabMode: false }) const FLEXIBLE_STRICT_WHITESPACE = true const initialDocumentLang = document.documentElement?.getAttribute('lang') || 'en' // Elements we should never translate const SKIP_TAGS = new Set([ 'SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'CANVAS', 'INPUT', 'TEXTAREA', 'SELECT', 'OPTION' ]) // IGNORE_PATTERN is now dynamic based on settings.exclusionSelectors const LOCALE_PRIMARY_BASE = 'https://webflow-ui-localization.pages.dev/src/locales' const LOCALE_SECONDARY_BASE = 'https://cdn.jsdelivr.net/gh/SPACESODA/Kumaflow@latest/src/locales' const LOCALE_CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours type CachedLocaleEntry = { dictionary: Dictionary; fetchedAt: number; source: 'primary' | 'secondary' } // --------------------------------------------------------------------------- // STATE // --------------------------------------------------------------------------- let activeReplacements: Replacement[] = [] let activeExactReplacements: Map = new Map() let reverseReplacements: Replacement[] = [] let reverseExactReplacements: Map = new Map() let currentLanguage: Exclude = DEFAULT_LANGUAGE let isEnabled = true let strictMatching = true let latestSettings: Settings = { ...DEFAULT_SETTINGS, exclusionSelectors: getDefaultExclusionSelectors() } // Loaded languages map (starts with bundled, may be updated via CDN) let loadedLanguages: Record, Dictionary> = { ...BUNDLED_LANGUAGES } let localeCache: Record, CachedLocaleEntry> = {} as any let cacheLoaded = false // Observers and Schedule let observer: MutationObserver | null = null let titleObserver: MutationObserver | null = null let flushScheduled = false const pendingTextNodes = new Set() const pendingElements = new Set() // --------------------------------------------------------------------------- // UTILS & FILTERS // --------------------------------------------------------------------------- function isDevtoolsNode(node: Node): boolean { if (!(node instanceof Element)) return false const id = node.id || '' if (id.startsWith('__web-inspector')) return true const className = typeof node.className === 'string' ? node.className : '' if (className.includes('devtools')) return true return false } function shouldProcessNode(node: Node): boolean { if (isDevtoolsNode(node)) return false return true } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function normalizeWhitespace(value: string): string { return value.replace(/\s+/g, ' ') } function buildFlexiblePattern(value: string): string { return normalizeWhitespace(value) .split(' ') .map((segment) => escapeRegExp(segment)) .join('\\s+') } function findBestMarker(source: string): string | undefined { const textOnly = source.replace(/\{[^}]+\}/g, ' ') const matches = textOnly.match(/[\p{L}\p{N}]+/gu) if (!matches) return undefined matches.sort((a, b) => b.length - a.length) if (matches[0] && matches[0].length >= 2) return matches[0] return undefined } // --------------------------------------------------------------------------- // REPLACEMENT BUILDER // --------------------------------------------------------------------------- function buildTokenizedReplacement( sourceString: string, targetString: string, flexible: boolean ): Replacement { // 1. Build Regex Pattern & Identify Tokens // Split keeps placeholder names because the capturing group is retained. // We iterate through the split parts once to populate `tokenNames` // so they exactly match the capture groups in the generated regex. const parts = sourceString.split(/\{([^}]+)\}/g) const toPattern = flexible ? buildFlexiblePattern : escapeRegExp const tokenNames: string[] = [] let patternString = '^(\\s*)' parts.forEach((part, index) => { const isToken = index % 2 === 1 if (isToken) { // it is a token (e.g. "name" from "{name}") // reconstruct the token with braces for identification const tokenName = `{${part}}` tokenNames.push(tokenName) // require at least one character to capture patternString += '(.+?)' } else if (part) { // static text patternString += toPattern(part) } }) patternString += '(\\s*)$' const regex = new RegExp(patternString) const marker = findBestMarker(sourceString) // 2. Build Replacement Function const replacement = (_match: string, ...args: any[]) => { // args: [leading, t1, t2, ..., tN, trailing, offset, string] // leading = args[0] // trailing = args[tokenNames.length + 1] const leading = args[0] const trailing = args[tokenNames.length + 1] // map token names to captured values const valuePool: Record = {} tokenNames.forEach((tokenName, i) => { if (!valuePool[tokenName]) valuePool[tokenName] = [] valuePool[tokenName].push(args[i + 1]) }) // copy pool to manage consumption of values const currentPool = { ...valuePool } for (const k in currentPool) { currentPool[k] = [...currentPool[k]] } // replace tokens in the target string with captured values const targetTokenRegex = /\{[^}]+\}/g const result = targetString.replace(targetTokenRegex, (token) => { if (currentPool[token] && currentPool[token].length > 0) { return currentPool[token].shift()! } // warn if missing a value for a placeholder (e.g. typo in translation or missing var) console.warn(`[Webflow-Localization] Missing value for token "${token}" in translation for: "${sourceString}"`) return token // leave placeholder as is }) return `${leading}${result}${trailing}` } return { regex, replacement, marker } } function buildReplacements(dictionary: Dictionary, strict: boolean): { exact: Map, complex: Replacement[] } { const entries = Object.entries(dictionary) // Treat empty strings as untranslated so we display the original text .filter(([_, replacement]) => replacement !== '') .sort(([a], [b]) => b.length - a.length) const exact = new Map() const complex: Replacement[] = [] if (strict) { entries.forEach(([source, replacement]) => { // 1. Complex Match: if source has placeholders, use regex if (source.includes('{') && source.includes('}')) { complex.push(buildTokenizedReplacement(source, replacement, FLEXIBLE_STRICT_WHITESPACE)) } else { // 2. Exact Match Optimization: use Map for O(1) lookup const key = normalizeWhitespace(source).trim() if (key) { exact.set(key, replacement) } } }) return { exact, complex } } // Non-strict mode always uses regex for partial matching (Legacy behavior) const legacyReplacements = entries.map(([source, replacement]: [string, string]) => ({ regex: new RegExp(escapeRegExp(source), 'g'), replacement, marker: source.slice(0, 6) })) return { exact: new Map(), complex: legacyReplacements } } function buildReverseReplacements(dictionary: Dictionary, strict: boolean): { exact: Map, complex: Replacement[] } { const entries = Object.entries(dictionary) // Ignore empty translations when reverting to avoid clearing text .filter(([_, replacement]) => replacement !== '') .sort(([a], [b]) => b.length - a.length) const exact = new Map() const complex: Replacement[] = [] if (strict) { entries.forEach(([source, replacement]) => { // Reverse direction: replacement is the key if (replacement.includes('{') && replacement.includes('}')) { complex.push(buildTokenizedReplacement(replacement, source, FLEXIBLE_STRICT_WHITESPACE)) } else { const key = normalizeWhitespace(replacement).trim() if (key) { exact.set(key, source) } } }) return { exact, complex } } const legacyReplacements = entries.map(([source, replacement]: [string, string]) => ({ regex: new RegExp(escapeRegExp(replacement), 'g'), replacement: source, marker: replacement.slice(0, 6) })) return { exact: new Map(), complex: legacyReplacements } } function maybeContains(text: string, marker?: string) { if (!marker) return true return text.includes(marker) } // --------------------------------------------------------------------------- // DOM MANIPULATION (Translate/Revert) // --------------------------------------------------------------------------- function applyReplacements( text: string, replacements: Replacement[], exactMap: Map ): { updated: string; changed: boolean } { if (!text.trim()) return { updated: text, changed: false } // 1. Exact Match Optimization (Strict Mode) // O(1) lookup for identifying common UI elements instantly. if (exactMap.size > 0) { const normalized = normalizeWhitespace(text).trim() if (exactMap.has(normalized)) { const replacement = exactMap.get(normalized)! // preserve leading/trailing whitespace from original text const match = text.match(/^(\s*)([\s\S]*?)(\s*)$/) if (match) { return { updated: match[1] + replacement + match[3], changed: true } } } } // 2. Complex/Partial Match // Iterates through regex patterns for dynamic content (e.g. placeholders). if (!replacements.length) return { updated: text, changed: false } let updated = text let changed = false for (let i = 0; i < replacements.length; i += 1) { const { regex, replacement, marker } = replacements[i] if (!maybeContains(updated, marker)) continue let next: string if (typeof replacement === 'function') { next = updated.replace(regex, replacement) } else { next = updated.replace(regex, replacement) } if (next !== updated) { updated = next changed = true } } return { updated, changed } } function translateTextNode(node: Text) { if (!isEnabled) return if (shouldSkipTextNode(node)) return const { updated, changed } = applyReplacements(node.data, activeReplacements, activeExactReplacements) if (changed) { node.data = updated } } function revertTextNode(node: Text) { // applySettings calls this to clear existing translations *while* isEnabled is still true. // The caller is responsible for deciding when to revert. const { updated, changed } = applyReplacements(node.data, reverseReplacements, reverseExactReplacements) if (changed) { node.data = updated } } function translateTitle() { if (!isEnabled) return const current = document.title const { updated, changed } = applyReplacements(current, activeReplacements, activeExactReplacements) if (changed && updated !== current) { document.title = updated } } function revertTitle() { const current = document.title const { updated, changed } = applyReplacements(current, reverseReplacements, reverseExactReplacements) if (changed) { document.title = updated } } const handleTitleMutations: MutationCallback = () => { // Logic: if the app overwrites our title with English, we re-apply translation. // The infinite loop is prevented by checking if translation is actually needed // inside translateTitle (via applyReplacements check). if (isEnabled) { translateTitle() } } function observeTitle() { const titleEl = document.querySelector('title') if (!titleEl) return // Should observe head if title doesn't exist yet? Webflow usually has it. if (!titleObserver) { titleObserver = new MutationObserver(handleTitleMutations) } else { titleObserver.disconnect() } titleObserver.observe(titleEl, { childList: true, characterData: true, subtree: true }) } function disconnectTitleObserver() { if (titleObserver) { titleObserver.disconnect() titleObserver = null } } function shouldSkipTextNode(textNode: Text) { const parent = textNode.parentElement if (!parent) return true if (SKIP_TAGS.has(parent.tagName)) return true if (parent.isContentEditable) return true // Dynamic Exclusion Check const effectiveSelectors = latestSettings.exclusionSelectors || EXCLUDED_SELECTORS if (effectiveSelectors.length > 0) { try { const pattern = effectiveSelectors.join(',') if (pattern.trim() && parent.closest(pattern)) return true } catch (e) { // invalid selector in user input might throw, ignore it to prevent crash // console.warn('Invalid selector in exclusion list', e) } } if (!textNode.textContent?.trim()) return true return false } function translateWithin(root: Node) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(textNode) { return shouldSkipTextNode(textNode as Text) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT } }) let current = walker.nextNode() as Text | null while (current) { translateTextNode(current) current = walker.nextNode() as Text | null } } function revertWithin(root: Node) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(textNode) { return shouldSkipTextNode(textNode as Text) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT } }) let current = walker.nextNode() as Text | null while (current) { revertTextNode(current) current = walker.nextNode() as Text | null } } function translatePlaceholder(element: HTMLInputElement | HTMLTextAreaElement) { if (!isEnabled) return const current = element.placeholder if (!current) return const { updated, changed } = applyReplacements(current, activeReplacements, activeExactReplacements) if (changed) { element.placeholder = updated } } function revertPlaceholder(element: HTMLInputElement | HTMLTextAreaElement) { const current = element.placeholder if (!current) return const { updated, changed } = applyReplacements(current, reverseReplacements, reverseExactReplacements) if (changed) { element.placeholder = updated } } function translatePlaceholdersWithin(root: Node) { if (root.nodeType === Node.ELEMENT_NODE) { const el = root as Element if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') { translatePlaceholder(el as HTMLInputElement | HTMLTextAreaElement) } const inputs = el.querySelectorAll('input[placeholder], textarea[placeholder]') inputs.forEach((input) => translatePlaceholder(input as HTMLInputElement | HTMLTextAreaElement)) } } function revertPlaceholdersWithin(root: Node) { if (root.nodeType === Node.ELEMENT_NODE) { const el = root as Element if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') { revertPlaceholder(el as HTMLInputElement | HTMLTextAreaElement) } const inputs = el.querySelectorAll('input[placeholder], textarea[placeholder]') inputs.forEach((input) => revertPlaceholder(input as HTMLInputElement | HTMLTextAreaElement)) } } // --------------------------------------------------------------------------- // SCHEDULING & BATCHING // --------------------------------------------------------------------------- function flushPending() { flushScheduled = false if (!document.body) return // Suspend observers to prevent infinite loops and performance issues. // Modifying the DOM while observing it would trigger immediate recursion. if (observer) observer.disconnect() if (titleObserver) titleObserver.disconnect() const textNodes = Array.from(pendingTextNodes) const elements = Array.from(pendingElements) pendingTextNodes.clear() pendingElements.clear() if (isEnabled) { textNodes.forEach((text) => translateTextNode(text)) elements.forEach((element) => { translateWithin(element) translatePlaceholdersWithin(element) }) } else { textNodes.forEach((text) => revertTextNode(text)) elements.forEach((element) => { revertWithin(element) revertPlaceholdersWithin(element) }) } injectDashboardFooter(currentLanguage, isEnabled, (updates: any) => { applySettings({ ...latestSettings, ...updates }) }) // Resume observers if (isEnabled) { observeDocument() observeTitle() } // If new mutations occurred during the flush (rare but possible), // schedule another pass to handle them. if (pendingTextNodes.size || pendingElements.size) { scheduleFlush() } } function scheduleFlush() { if (flushScheduled) return flushScheduled = true const runner = () => { flushPending() } // Use requestAnimationFrame to update before the next repaint. // This minimizes the "flash of untranslated content" for dynamic UI elements. requestAnimationFrame(runner) } const handleDocumentMutations: MutationCallback = (mutations) => { mutations.forEach((mutation) => { if (!shouldProcessNode(mutation.target)) { return } if (mutation.type === 'characterData') { if (mutation.target.nodeType === Node.TEXT_NODE) { pendingTextNodes.add(mutation.target as Text) } } else if (mutation.type === 'attributes' && mutation.attributeName === 'placeholder') { // optimization: we track the element so we can rescan its placeholders const target = mutation.target as HTMLInputElement | HTMLTextAreaElement pendingElements.add(target) } mutation.addedNodes.forEach((node) => { if (!shouldProcessNode(node)) return if (node.nodeType === Node.TEXT_NODE) { pendingTextNodes.add(node as Text) } else if (node.nodeType === Node.ELEMENT_NODE) { pendingElements.add(node as Element) } }) }) if (pendingTextNodes.size || pendingElements.size) { scheduleFlush() } } function observeDocument() { if (!observer) { observer = new MutationObserver(handleDocumentMutations) } else { observer.disconnect() } observer.observe(document.body, { characterData: true, childList: true, subtree: true, attributes: true, attributeFilter: ['placeholder'] }) } function disconnectObserver() { if (observer) { observer.disconnect() observer = null } pendingTextNodes.clear() pendingElements.clear() } // --------------------------------------------------------------------------- // STORAGE & SETTINGS // --------------------------------------------------------------------------- function getStorage(): chrome.storage.SyncStorageArea | chrome.storage.LocalStorageArea { if (chrome?.storage?.sync) return chrome.storage.sync return chrome.storage.local } function getCacheStorage(): chrome.storage.LocalStorageArea | chrome.storage.SyncStorageArea { // Cache can be larger; prefer local to avoid sync quotas. if (chrome?.storage?.local) return chrome.storage.local return chrome.storage.sync } function getSavedSettings(): Promise { const storage = getStorage() return new Promise((resolve) => { storage.get(DEFAULT_SETTINGS, (result) => { resolve(normalizeSettings({ ...DEFAULT_SETTINGS, ...result })) }) }) } function updateDocumentLang(language: LanguageCode, enabled: boolean) { const langToSet = enabled && language !== 'off' ? language : initialDocumentLang document.documentElement?.setAttribute('lang', langToSet) } // --------------------------------------------------------------------------- // FETCHING & CACHING // --------------------------------------------------------------------------- function buildLocaleUrls(code: Exclude): Array<{ url: string; source: 'primary' | 'secondary' }> { return [ { url: `${LOCALE_PRIMARY_BASE}/${code}.json`, source: 'primary' }, { url: `${LOCALE_SECONDARY_BASE}/${code}.json`, source: 'secondary' } ] } async function fetchLocale(url: string): Promise { const response = await fetch(url, { cache: 'no-cache' }) if (!response.ok) { throw new Error(`Failed to fetch locale: ${response.status}`) } const data = await response.json() if (!data || typeof data !== 'object') { throw new Error('Invalid locale JSON') } return data as Dictionary } async function loadLocaleCacheFromStorage(): Promise { if (cacheLoaded) return const storage = getCacheStorage() await new Promise((resolve) => { storage.get({ [LOCALE_CACHE_KEY]: {} }, (result) => { const cached = (result as any)[LOCALE_CACHE_KEY] || {} const now = Date.now() Object.entries(cached as Record).forEach(([code, entry]) => { if (now - entry.fetchedAt < LOCALE_CACHE_TTL) { localeCache[code as Exclude] = entry } }) cacheLoaded = true resolve() }) }) } function persistLocaleCache() { try { getCacheStorage().set({ [LOCALE_CACHE_KEY]: localeCache }) } catch (err) { console.warn('Could not persist locale cache', err) } } function getCachedLocale( code: Exclude ): CachedLocaleEntry | null { const entry = localeCache[code] if (!entry) return null if (Date.now() - entry.fetchedAt > LOCALE_CACHE_TTL) return null return entry } function cacheLocale( code: Exclude, dictionary: Dictionary, source: 'primary' | 'secondary' ) { localeCache[code] = { dictionary, fetchedAt: Date.now(), source } persistLocaleCache() } async function fetchLocaleWithFallback(code: Exclude): Promise { const sources = buildLocaleUrls(code) for (let i = 0; i < sources.length; i += 1) { const { url, source } = sources[i] try { const locale = await fetchLocale(url) cacheLocale(code, locale, source) return locale } catch (err) { console.warn(`Could not fetch locale for ${code} from ${source}`, err) } } return null } async function primeLocalesFromCache() { await loadLocaleCacheFromStorage() Object.entries(localeCache).forEach(([code, entry]) => { loadedLanguages[code as Exclude] = entry.dictionary }) } async function refreshLocalesFromCdn() { if (!latestSettings.useCdn) return await loadLocaleCacheFromStorage() const updates: Partial, Dictionary>> = {} const codes = Object.keys(BUNDLED_LANGUAGES) as Exclude[] const now = Date.now() await Promise.all( codes.map(async (code) => { const cached = getCachedLocale(code) if (cached && now - cached.fetchedAt < LOCALE_CACHE_TTL) { updates[code] = cached.dictionary return } const locale = await fetchLocaleWithFallback(code) if (locale) { updates[code] = locale } else if (cached) { // Use stale cache as a soft fallback if network fails. updates[code] = cached.dictionary } }) ) if (Object.keys(updates).length) { loadedLanguages = { ...loadedLanguages, ...updates } applySettings(latestSettings) } } // --------------------------------------------------------------------------- // MAIN CONTROLLER // --------------------------------------------------------------------------- function applySettings(settings: Settings) { const normalized = normalizeSettings(settings) // 1. Revert existing translations if currently enabled. // Ensures a clean slate (English) before applying new language or disqualifying. if (isEnabled) { disconnectObserver() disconnectTitleObserver() revertWithin(document.body) revertPlaceholdersWithin(document.body) revertTitle() } // 2. Update state latestSettings = normalized const language = normalized.language === 'off' ? currentLanguage : normalized.language // 3. Determine which dictionary to use let dictionary: Dictionary | undefined if (normalized.useCdn) { // If CDN is enabled, prefer the loaded (external/cached) dictionary, fallback to bundle. dictionary = loadedLanguages[language] ?? BUNDLED_LANGUAGES[language] } else { // If CDN is disabled, strictly use the bundled dictionary. dictionary = BUNDLED_LANGUAGES[language] } // Fallback to default language if needed if (!dictionary) { // If CDN was enabled but the specific language wasn't found in loadedLanguages // (e.g., failed to fetch), try the default language from loadedLanguages. if (normalized.useCdn) { dictionary = loadedLanguages[DEFAULT_LANGUAGE] } // Final fallback: use the bundled default language. if (!dictionary) { dictionary = BUNDLED_LANGUAGES[DEFAULT_LANGUAGE] } } currentLanguage = language isEnabled = normalized.enabled && normalized.language !== 'off' strictMatching = normalized.strictMatching // Build the replacement maps (Exact vs Regex) const built = buildReplacements(dictionary, strictMatching) activeReplacements = built.complex activeExactReplacements = built.exact const builtReverse = buildReverseReplacements(dictionary, strictMatching) reverseReplacements = builtReverse.complex reverseExactReplacements = builtReverse.exact updateDocumentLang(currentLanguage, isEnabled) if (normalized.grabMode) { enableGrabMode() } else { disableGrabMode() } // 4. Apply new translations if enabled if (isEnabled) { translateWithin(document.body) translatePlaceholdersWithin(document.body) translateTitle() observeDocument() observeTitle() } // Update footer regardless of enabled state (to show English when disabled) // We pass a callback to allow the footer dropdown to trigger immediate updates manually injectDashboardFooter(currentLanguage, isEnabled, (updates) => { applySettings({ ...latestSettings, ...updates }) }) } function listenForSettingsChanges() { chrome.storage.onChanged.addListener((changes, areaName) => { if (areaName !== 'sync' && areaName !== 'local') return if ( !changes.language && typeof changes.enabled === 'undefined' && typeof changes.strictMatching === 'undefined' && typeof changes.useCdn === "undefined" && typeof changes.grabMode === "undefined" && typeof changes.exclusionSelectors === 'undefined' ) return let hasChange = false const newSettings: Settings = { ...latestSettings } if (changes.language) { newSettings.language = changes.language.newValue as LanguageCode hasChange = true } if (changes.enabled) { newSettings.enabled = changes.enabled.newValue as boolean hasChange = true } if (changes.strictMatching) { newSettings.strictMatching = changes.strictMatching.newValue hasChange = true } if (changes.useCdn) { newSettings.useCdn = changes.useCdn.newValue; hasChange = true; } if (changes.grabMode) { newSettings.grabMode = changes.grabMode.newValue; hasChange = true; } if (changes.exclusionSelectors) { newSettings.exclusionSelectors = resolveExclusionSelectors(changes.exclusionSelectors.newValue) hasChange = true } if (hasChange) { applySettings(newSettings) } }) } function init() { if (!document.body) return getSavedSettings() .then(async (settings) => { await primeLocalesFromCache() applySettings(settings) listenForSettingsChanges() refreshLocalesFromCdn() }) .catch((err) => { console.warn('Failed to load saved settings', err) refreshLocalesFromCdn() }) } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init) } else { init() }