UNPKG

i18next-locize-backend

Version:

i18next-locize-backend is a backend layer for i18next to use locize service which can be used in node.js, in the browser and for deno.

679 lines (597 loc) 22.6 kB
import { defaults, debounce, isMissingOption, interpolate, getPath, setPath, pushPath, defer } from './utils.js' import request from './request.js' const getDefaults = () => { return { loadPath: 'https://api.locize.app/{{projectId}}/{{version}}/{{lng}}/{{ns}}', privatePath: 'https://api.locize.app/private/{{projectId}}/{{version}}/{{lng}}/{{ns}}', getLanguagesPath: 'https://api.locize.app/languages/{{projectId}}', addPath: 'https://api.locize.app/missing/{{projectId}}/{{version}}/{{lng}}/{{ns}}', updatePath: 'https://api.locize.app/update/{{projectId}}/{{version}}/{{lng}}/{{ns}}', referenceLng: 'en', crossDomain: true, setContentTypeJSON: false, version: 'latest', private: false, translatedPercentageThreshold: 0.9, failLoadingOnEmptyJSON: false, // useful if using chained backend allowedAddOrUpdateHosts: ['localhost'], onSaved: false, reloadInterval: typeof window !== 'undefined' ? false : 60 * 60 * 1000, checkForProjectTimeout: 3 * 1000, storageExpiration: 60 * 60 * 1000, writeDebounce: 5 * 1000 } } let hasLocalStorageSupport try { hasLocalStorageSupport = typeof window !== 'undefined' && window.localStorage !== null const testKey = 'notExistingLocizeProject' window.localStorage.setItem(testKey, 'foo') window.localStorage.removeItem(testKey) } catch (e) { hasLocalStorageSupport = false } function getStorage (storageExpiration) { let setProjectNotExisting = () => {} let isProjectNotExisting = () => {} if (hasLocalStorageSupport) { setProjectNotExisting = (projectId) => { window.localStorage.setItem(`notExistingLocizeProject_${projectId}`, Date.now()) } isProjectNotExisting = (projectId) => { const ret = window.localStorage.getItem(`notExistingLocizeProject_${projectId}`) if (!ret) return false if (Date.now() - ret > storageExpiration) { window.localStorage.removeItem(`notExistingLocizeProject_${projectId}`) return false } return true } } else if (typeof document !== 'undefined') { setProjectNotExisting = (projectId) => { const date = new Date() date.setTime(date.getTime() + storageExpiration) const expires = `; expires=${date.toGMTString()}` const name = `notExistingLocizeProject_${projectId}` try { // ignore if running in strange environments document.cookie = `${name}=${Date.now()}${expires};path=/` } catch (err) {} } isProjectNotExisting = (projectId) => { const name = `notExistingLocizeProject_${projectId}` const nameEQ = `${name}=` try { // ignore if running in strange environments const ca = document.cookie.split(';') for (let i = 0; i < ca.length; i++) { let c = ca[i] while (c.charAt(0) === ' ') c = c.substring(1, c.length) if (c.indexOf(nameEQ) === 0) return true // return c.substring(nameEQ.length,c.length); } } catch (err) {} return false } } return { setProjectNotExisting, isProjectNotExisting } } const getCustomRequestInfo = (url, options, payload) => { const headers = {} if (options.authorize && options.apiKey) { headers.Authorization = options.apiKey } if (payload || options.setContentTypeJSON) { headers['Content-Type'] = 'application/json' } return { method: payload ? 'POST' : 'GET', url, headers, body: payload } } const handleCustomRequest = (opt, info, cb) => { if (opt.request.length === 1) { // no callback try { const r = opt.request(info) if (r && typeof r.then === 'function') { // promise r.then((data) => cb(null, data)).catch(cb) } else { // sync cb(null, r) } } catch (err) { cb(err) } return } // normal with callback opt.request(info, cb) } class I18NextLocizeBackend { constructor (services, options = {}, allOptions = {}, callback) { this.services = services this.options = options this.allOptions = allOptions this.type = 'backend' if (services && services.projectId) { this.init(null, services, allOptions, options) } else { this.init(services, options, allOptions, callback) } } init (services, options = {}, allOptions = {}, callback) { if (!options.referenceLng && allOptions.fallbackLng && Array.isArray(allOptions.fallbackLng) && allOptions.fallbackLng[0] !== 'dev') { options.referenceLng = allOptions.fallbackLng[0] } this.services = services const defOpt = getDefaults() const passedOpt = defaults(options, this.options || {}) if (passedOpt.reloadInterval && passedOpt.reloadInterval < (5 * 60 * 1000)) { console.warn('Your configured reloadInterval option is to low.') passedOpt.reloadInterval = defOpt.reloadInterval } this.options = defaults(options, this.options || {}, defOpt) this.allOptions = allOptions this.somethingLoaded = false this.isProjectNotExisting = false this.storage = getStorage(this.options.storageExpiration) if (this.options.pull) { console.warn( 'The pull API was removed use "private: true" option instead: https://www.locize.com/docs/api#fetch-private-namespace-resources' ) } const hostname = typeof window !== 'undefined' && window.location && window.location.hostname if (hostname) { this.isAddOrUpdateAllowed = typeof this.options.allowedAddOrUpdateHosts === 'function' ? this.options.allowedAddOrUpdateHosts(hostname) : this.options.allowedAddOrUpdateHosts.indexOf(hostname) > -1 if (services && services.logger && (allOptions.saveMissing || allOptions.updateMissing)) { if (!this.isAddOrUpdateAllowed) { services.logger.warn( typeof this.options.allowedAddOrUpdateHosts === 'function' ? `locize-backend: will not save or update missings because allowedAddOrUpdateHosts returned false for the host "${hostname}".` : `locize-backend: will not save or update missings because the host "${hostname}" was not in the list of allowedAddOrUpdateHosts: ${this.options.allowedAddOrUpdateHosts.join( ', ' )} (matches need to be exact).` ) } else if (hostname !== 'localhost') { services.logger.warn(`locize-backend: you are using the save or update missings feature from this host "${hostname}".\nMake sure you will not use it in production!\nhttps://www.locize.com/docs/going-to-production`) } } } else { this.isAddOrUpdateAllowed = true } if (typeof callback === 'function') { this.getOptions((err, opts, languages) => { if (err) return callback(err) this.options.referenceLng = options.referenceLng || opts.referenceLng || this.options.referenceLng callback(null, opts, languages) }) } this.queuedWrites = { pending: {} } this.debouncedProcess = debounce(this.process, this.options.writeDebounce) if (this.interval) clearInterval(this.interval) if (this.options.reloadInterval && this.options.projectId) { this.interval = setInterval(() => this.reload(), this.options.reloadInterval) if (typeof this.interval === 'object' && typeof this.interval.unref === 'function') this.interval.unref() } } reload () { const { backendConnector, languageUtils, logger } = this.services || { logger: console } if (!backendConnector) return const currentLanguage = backendConnector.language if (currentLanguage && currentLanguage.toLowerCase() === 'cimode') return // avoid loading resources for cimode const toLoad = [] const append = (lng) => { const lngs = languageUtils.toResolveHierarchy(lng) lngs.forEach(l => { if (toLoad.indexOf(l) < 0) toLoad.push(l) }) } append(currentLanguage) if (this.allOptions.preload) this.allOptions.preload.forEach((l) => append(l)) toLoad.forEach(lng => { this.allOptions.ns.forEach(ns => { backendConnector.read(lng, ns, 'read', null, null, (err, data) => { if (err) logger.warn(`loading namespace ${ns} for language ${lng} failed`, err) if (!err && data) logger.log(`loaded namespace ${ns} for language ${lng}`, data) backendConnector.loaded(`${lng}|${ns}`, err, data) }) }) }) } getLanguages (callback) { let deferred if (!callback) { deferred = defer() callback = (err, ret) => { if (err) return deferred.reject(err) deferred.resolve(ret) } } const isMissing = isMissingOption(this.options, ['projectId']) if (isMissing) { callback(new Error(isMissing)) return deferred } const url = interpolate(this.options.getLanguagesPath, { projectId: this.options.projectId }) if (!this.isProjectNotExisting && this.storage.isProjectNotExisting(this.options.projectId)) { this.isProjectNotExisting = true } if (this.isProjectNotExisting) { callback(new Error(`locize project ${this.options.projectId} does not exist!`)) return deferred } // there are scenarios/users that seems to call getLanguges a lot of time this.getLanguagesCalls = this.getLanguagesCalls || [] this.getLanguagesCalls.push(callback) if (this.getLanguagesCalls.length > 1) return deferred this.loadUrl({}, url, (err, ret, info) => { if (!this.somethingLoaded && info && info.resourceNotExisting) { this.isProjectNotExisting = true this.storage.setProjectNotExisting(this.options.projectId) const e = new Error(`locize project ${this.options.projectId} does not exist!`) const clbs = this.getLanguagesCalls this.getLanguagesCalls = [] return clbs.forEach((clb) => clb(e)) } if (ret) { const referenceLng = Object.keys(ret).reduce((mem, k) => { const item = ret[k] if (item.isReferenceLanguage) mem = k return mem }, '') if (referenceLng && this.options.referenceLng !== referenceLng) { this.options.referenceLng = referenceLng } } this.somethingLoaded = true const clbs = this.getLanguagesCalls this.getLanguagesCalls = [] clbs.forEach((clb) => clb(err, ret)) }) return deferred } getOptions (callback) { let deferred if (!callback) { deferred = defer() callback = (err, ret) => { if (err) return deferred.reject(err) deferred.resolve(ret) } } this.getLanguages((err, data) => { if (err) return callback(err) const keys = Object.keys(data) if (!keys.length) { return callback(new Error('was unable to load languages via API')) } const lngs = keys.reduce((mem, k) => { const item = data[k] if ( item.translated[this.options.version] && item.translated[this.options.version] >= this.options.translatedPercentageThreshold ) { mem.push(k) } return mem }, []) const hasRegion = keys.reduce((mem, k) => { if (k.indexOf('-') > -1) return true return mem }, false) callback(null, { fallbackLng: this.options.referenceLng, referenceLng: this.options.referenceLng, supportedLngs: (lngs.length === 0 && this.options.referenceLng) ? [this.options.referenceLng] : lngs, load: hasRegion ? 'all' : 'languageOnly' }, data) }) return deferred } checkIfProjectExists (callback) { const { logger } = this.services || { logger: console } if (this.somethingLoaded) { if (callback) callback(null) return } if (this.alreadyRequestedCheckIfProjectExists) { setTimeout(() => this.checkIfProjectExists(callback), this.options.checkForProjectTimeout) return } this.alreadyRequestedCheckIfProjectExists = true this.getLanguages((err) => { if (err && err.message && err.message.indexOf('does not exist') > 0) { if (logger) logger.error(err.message) } if (callback) callback(err) }) } read (language, namespace, callback) { const { logger } = this.services || { logger: console } let url let options = {} if (this.options.private) { const isMissing = isMissingOption(this.options, ['projectId', 'version', 'apiKey']) if (isMissing) return callback(new Error(isMissing), false) url = interpolate(this.options.privatePath, { lng: language, ns: namespace, projectId: this.options.projectId, version: this.options.version }) options = { authorize: true } } else { const isMissing = isMissingOption(this.options, ['projectId', 'version']) if (isMissing) return callback(new Error(isMissing), false) url = interpolate(this.options.loadPath, { lng: language, ns: namespace, projectId: this.options.projectId, version: this.options.version }) } if (!this.isProjectNotExisting && this.storage.isProjectNotExisting(this.options.projectId)) { this.isProjectNotExisting = true } if (this.isProjectNotExisting) { const err = new Error(`locize project ${this.options.projectId} does not exist!`) if (logger) logger.error(err.message) if (callback) callback(err) return } this.loadUrl(options, url, (err, ret, info) => { if (!this.somethingLoaded) { if (info && info.resourceNotExisting) { setTimeout(() => this.checkIfProjectExists(), this.options.checkForProjectTimeout) } else { this.somethingLoaded = true } } callback(err, ret) }) } loadUrl (options, url, payload, callback) { options = defaults(options, this.options) if (typeof payload === 'function') { callback = payload payload = undefined } callback = callback || (() => {}) const clb = (err, res) => { const resourceNotExisting = res && res.resourceNotExisting if (res && (res.status === 408 || res.status === 400)) { // extras for timeouts on cloudfront return callback('failed loading ' + url, true /* retry */, { resourceNotExisting }) } if (res && ((res.status >= 500 && res.status < 600) || !res.status)) { return callback('failed loading ' + url, true /* retry */, { resourceNotExisting }) } if (res && res.status >= 400 && res.status < 500) { return callback('failed loading ' + url, false /* no retry */, { resourceNotExisting }) } if (!res && err && err.message) { const errorMessage = err.message.toLowerCase() // for example: // Chrome: "Failed to fetch" // Firefox: "NetworkError when attempting to fetch resource." // Safari: "Load failed" const isNetworkError = [ 'failed', 'fetch', 'network', 'load' ].find((term) => errorMessage.indexOf(term) > -1) if (isNetworkError) { return callback('failed loading ' + url + ': ' + err.message, true /* retry */, { resourceNotExisting }) } } if (err) return callback(err, false) let ret, parseErr try { if (typeof res.data === 'string') { ret = JSON.parse(res.data) } else { // fallback, which omits calling the parse function ret = res.data } } catch (e) { parseErr = 'failed parsing ' + url + ' to json' } if (parseErr) return callback(parseErr, false) if (this.options.failLoadingOnEmptyJSON && !Object.keys(ret).length) { return callback('loaded result empty for ' + url, false, { resourceNotExisting }) } callback(null, ret, { resourceNotExisting }) } if (!this.options.request || url.indexOf(`/languages/${options.projectId}`) > 0) return request(options, url, payload, clb) const info = getCustomRequestInfo(url, options, payload) handleCustomRequest(this.options, info, clb) } create (languages, namespace, key, fallbackValue, callback, options) { if (typeof callback !== 'function') callback = () => {} this.checkIfProjectExists((err) => { if (err) return callback(err) // missing options const isMissing = isMissingOption(this.options, ['projectId', 'version', 'apiKey', 'referenceLng']) if (isMissing) return callback(new Error(isMissing)) // unallowed host if (!this.isAddOrUpdateAllowed) { return callback('host is not allowed to create key.') } if (typeof languages === 'string') languages = [languages] if (languages.filter(l => l === this.options.referenceLng).length < 1) { this.services && this.services.logger && this.services.logger.warn( `locize-backend: will not save missings because the reference language "${ this.options.referenceLng }" was not in the list of to save languages: ${languages.join( ', ' )} (open your site in the reference language to save missings).` ) } languages.forEach(lng => { if (lng === this.options.referenceLng) { // eslint-disable-next-line no-useless-call this.queue.call( this, this.options.referenceLng, namespace, key, fallbackValue, callback, options ) } }) }) } update (languages, namespace, key, fallbackValue, callback, options) { if (typeof callback !== 'function') callback = () => {} this.checkIfProjectExists((err) => { if (err) return callback(err) // missing options const isMissing = isMissingOption(this.options, ['projectId', 'version', 'apiKey', 'referenceLng']) if (isMissing) return callback(new Error(isMissing)) if (!this.isAddOrUpdateAllowed) { return callback('host is not allowed to update key.') } if (!options) options = {} if (typeof languages === 'string') languages = [languages] // mark as update options.isUpdate = true languages.forEach(lng => { if (lng === this.options.referenceLng) { // eslint-disable-next-line no-useless-call this.queue.call( this, this.options.referenceLng, namespace, key, fallbackValue, callback, options ) } }) }) } writePage (lng, namespace, missings, callback) { const missingUrl = interpolate(this.options.addPath, { lng, ns: namespace, projectId: this.options.projectId, version: this.options.version }) const updatesUrl = interpolate(this.options.updatePath, { lng, ns: namespace, projectId: this.options.projectId, version: this.options.version }) let hasMissing = false let hasUpdates = false const payloadMissing = {} const payloadUpdate = {} missings.forEach(item => { const value = item.options && item.options.tDescription ? { value: item.fallbackValue || '', context: { text: item.options.tDescription } } : item.fallbackValue || '' if (item.options && item.options.isUpdate) { if (!hasUpdates) hasUpdates = true payloadUpdate[item.key] = value } else { if (!hasMissing) hasMissing = true payloadMissing[item.key] = value } }) let todo = 0 if (hasMissing) todo++ if (hasUpdates) todo++ const doneOne = (err) => { todo-- if (!todo) callback(err) } if (!todo) doneOne() if (hasMissing) { if (!this.options.request) { request( defaults({ authorize: true }, this.options), missingUrl, payloadMissing, doneOne ) } else { const info = getCustomRequestInfo(missingUrl, defaults({ authorize: true }, this.options), payloadMissing) handleCustomRequest(this.options, info, doneOne) } } if (hasUpdates) { if (!this.options.request) { request( defaults({ authorize: true }, this.options), updatesUrl, payloadUpdate, doneOne ) } else { const info = getCustomRequestInfo(updatesUrl, defaults({ authorize: true }, this.options), payloadUpdate) handleCustomRequest(this.options, info, doneOne) } } } write (lng, namespace) { const lock = getPath(this.queuedWrites, ['locks', lng, namespace]) if (lock) return const missings = getPath(this.queuedWrites, [lng, namespace]) setPath(this.queuedWrites, [lng, namespace], []) const pageSize = 1000 const clbs = missings.filter(m => m.callback).map(missing => missing.callback) if (missings.length) { // lock setPath(this.queuedWrites, ['locks', lng, namespace], true) const namespaceSaved = () => { // unlock setPath(this.queuedWrites, ['locks', lng, namespace], false) clbs.forEach(clb => clb()) // emit notification onSaved if (this.options.onSaved) this.options.onSaved(lng, namespace) // rerun this.debouncedProcess(lng, namespace) } const amountOfPages = missings.length / pageSize let pagesDone = 0 let page = missings.splice(0, pageSize) this.writePage(lng, namespace, page, () => { pagesDone++ if (pagesDone >= amountOfPages) namespaceSaved() }) while (page.length === pageSize) { page = missings.splice(0, pageSize) if (page.length) { this.writePage(lng, namespace, page, () => { pagesDone++ if (pagesDone >= amountOfPages) namespaceSaved() }) } } } } process () { Object.keys(this.queuedWrites).forEach(lng => { if (lng === 'locks') return Object.keys(this.queuedWrites[lng]).forEach(ns => { const todo = this.queuedWrites[lng][ns] if (todo.length) { this.write(lng, ns) } }) }) } queue (lng, namespace, key, fallbackValue, callback, options) { pushPath(this.queuedWrites, [lng, namespace], { key, fallbackValue: fallbackValue || '', callback, options }) this.debouncedProcess() } } I18NextLocizeBackend.type = 'backend' export default I18NextLocizeBackend