sanity
Version:
Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches
221 lines (195 loc) • 6.84 kB
text/typescript
import {type BackendModule, type ReadCallback} from 'i18next'
import {isPlainObject} from 'lodash'
import {type LocaleResourceBundle, type LocaleResourceKey, type LocaleResourceRecord} from './types'
/**
* Options for the Sanity i18next backend
*
* @internal
* @hidden
*/
export interface SanityI18nBackendOptions {
bundles: LocaleResourceBundle[]
}
/**
* Creates a "backend" for i18next that loads locale resources defined in configuration/plugins.
*
* This allows us to dynamically load only the resources used. For instance, if the user requests
* the `vision` namespace and is using the `fr` locale, we skip loading all the other locales.
*
* Note that this only works if the locale bundles are defined with an async function for the
* `resources` key, usually by using a dynamic import (`import('some/path/en.js')`. Otherwise,
* the resources will be loaded at once.
*
* @param options - Options for the backend
* @returns A backend module for i18next
* @internal
* @hidden
*/
export function createSanityI18nBackend(options: SanityI18nBackendOptions): BackendModule {
const {bundles} = options
function init() {
// intentional noop - i18next requires a init function, but we don't need it
}
function read(locale: string, namespace: string, callback: ReadCallback) {
const loadable = bundles.filter(
(bundle) => bundle.locale === locale && bundle.namespace === namespace,
)
if (loadable.length === 0) {
// @todo warn? This means someone requested a namespace/locale combination that there are no resources for
callback(
`No translations found for namespace "${namespace}", language "${locale}"`,
undefined, // Returning undefined here will i18next _not_ retry
)
return
}
loadBundles(loadable)
.then((resources) => callback(null, resources))
// Returning true for second parameter will make i18next retry.
// It handles the retry internally, and has both max retries and timeouts.
.catch((err) => callback(err, true))
}
return {
type: 'backend',
init,
read,
}
}
/**
* Load the given locale bundles, and return a promise for a merged resource object.
*
* @param bundles - Array of bundles to load resources for
* @returns An object of locale resources
* @remarks
* - The bundles passed **MUST** be for the same namespace and locale!
* - The algorithm differs from i18next:
* - in i18next, if `deep` is `false`, `overwrite` is _always_ `true`
* - in Sanity, `overwrite` is always respected
* @internal
* @hidden
*/
async function loadBundles(bundles: LocaleResourceBundle[]): Promise<LocaleResourceRecord> {
// Resolve resources in parallell to avoid waiting for each bundle as we extend
// Note: we may want a queue for this if people do very dynamic loading strategies
const resolved = await Promise.all(
bundles.map(async (bundle) => ({
...bundle,
resources: await loadBundleResources(bundle),
})),
)
const base: LocaleResourceRecord = {}
for (const item of resolved) {
const deep = item.deep ?? true
const overwrite = item.overwrite ?? true
if (deep) {
deepExtend(base, item.resources, overwrite)
} else if (overwrite) {
Object.assign(base, item.resources)
} else {
Object.assign({}, item.resources, base)
}
}
return base
}
/**
* Loads the resources of a bundle, calling any function and unwrapping any default module exports.
*
* @param bundle - Bundle to load resources for
* @returns Record of resources
*/
async function loadBundleResources(bundle: LocaleResourceBundle): Promise<LocaleResourceRecord> {
if (typeof bundle.resources !== 'function') {
return bundle.resources
}
const resources = await bundle.resources()
return maybeUnwrapModule(resources)
}
/**
* Deeply extend an object of resources, taking into account flat string shapes and nested objects.
*
* Typescripted version of i18next's internal utility for the same operation, see
* {@link https://github.com/i18next/i18next/blob/v23.2.11/src/utils.js#L89}
*
* We need this because we're letting the backend do the merging instead of using `addResourceBundle`.
*
* @param target - Target object to extend
* @param source - Source object to merge into target
* @param overwrite - Whether to overwrite existing strings/objects
* @returns A merged object
* @internal
*/
function deepExtend(
target: LocaleResourceRecord,
source: LocaleResourceRecord,
overwrite = false,
): LocaleResourceRecord {
for (const prop in source) {
if (prop === '__proto__' || prop === 'constructor') {
continue
}
// Assign missing properties directly
if (!(prop in target)) {
target[prop] = source[prop]
continue
}
const targetLeaf = target[prop]
const sourceLeaf = source[prop]
const targetIsString = isStringLeaf(targetLeaf)
const sourceIsString = isStringLeaf(sourceLeaf)
// We reached a leaf string in target OR source
if ((targetIsString || sourceIsString) && overwrite) {
target[prop] = source[prop]
continue
}
if (targetIsString || sourceIsString) {
// Skip, since we are not overwriting
continue
}
// If we're overwriting with an array, don't try to merge objects/arrays, just overwrite
const sourceIsArray = Array.isArray(sourceLeaf)
const targetIsArray = Array.isArray(targetLeaf)
if (sourceIsArray || targetIsArray) {
// Nothing to do here if we can't overwrite
if (overwrite) {
target[prop] = sourceLeaf
}
continue
}
// Recurse deeper since we haven't reached a leaf
deepExtend(targetLeaf, sourceLeaf, overwrite)
}
return target
}
/**
* Returns whether or not the target is leaf, eg a string
*
* @param target - The target to check
* @returns True if string/instance of string, false otherwise
* @internal
*/
function isStringLeaf(target: LocaleResourceKey): target is string {
return typeof target === 'string' || target instanceof String
}
/**
* Unwraps an imported module if it only contains a default export
*
* @param maybeModule - Module to unwrap
* @returns Unwrapped resource record
* @internal
*/
function maybeUnwrapModule(
maybeModule: LocaleResourceRecord | {default: LocaleResourceRecord},
): LocaleResourceRecord {
return isWrappedModule(maybeModule) ? maybeModule.default : maybeModule
}
/**
* Checks whether or not the passed item is wrapped
*
* @param mod - Item to check whether or not is wrapped
* @returns True if wrapped, false otherwise
* @internal
*/
function isWrappedModule(
mod: LocaleResourceRecord | {default: LocaleResourceRecord},
): mod is {default: LocaleResourceRecord} {
return 'default' in mod && typeof mod.default === 'object' && isPlainObject(mod.default)
}