runtime/shared/fbt.js (293 lines of code) (raw):

/** * (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary. * * Flow doesn't know about the transformations of fbt() calls into tables, so * all it sees is that callers are adding strings and arrays, which isn't * allowed so flow for this file is ignored in .flowconfig. * * This file is shared between www and fbsource and www is the source of truth. * When you make change to this file on www, please make sure you test it on * fbsource and send a diff to update the files too so that the 2 versions are * kept in sync. * * Run the following command to sync the change from www to fbsource. * js1 upgrade www-shared -p fbt --local ~/www * * @flow strict-local * @typechecks * @format * @emails oncall+i18n_fbt_js */ /* eslint-disable fb-www/order-requires */ import type {FbtInputOpts, FbtRuntimeInput, FbtTableArgs} from 'FbtHooks'; import type {ParamVariationType, ValidPronounUsagesType} from 'FbtRuntimeTypes'; import type {FbtTableKey, PatternHash, PatternString} from 'FbtTable'; import type {FbtTableArg} from 'FbtTableAccessor'; import type {GenderConstEnum} from 'GenderConst'; const FbtEnv = require('FbtEnv'); FbtEnv.setupOnce(); const FbtHooks = require('FbtHooks'); const {overrides} = require('FbtQTOverrides'); const FbtResultBase = require('FbtResultBase'); const FbtTable = require('FbtTable'); const FbtTableAccessor = require('FbtTableAccessor'); const GenderConst = require('GenderConst'); const { getGenderVariations, getNumberVariations, } = require('IntlVariationResolver'); const intlNumUtils = require('intlNumUtils'); const invariant = require('invariant'); const substituteTokens = require('substituteTokens'); /* * $FlowFixMe[method-unbinding] Use original method in case the token names contain * a 'hasOwnProperty' key too; or if userland code redefined that method. */ const {hasOwnProperty} = Object.prototype; let jsonExportMode = false; // Used only in React Native const {ARG} = FbtTable; const ParamVariation: ParamVariationType = { number: 0, gender: 1, }; const ValidPronounUsages: ValidPronounUsagesType = { object: 0, possessive: 1, reflexive: 2, subject: 3, }; const cachedFbtResults: {[patternStr: PatternString]: Fbt} = {}; /** * fbt._() iterates through all indices provided in `args` and accesses * the relevant entry in the `table` resulting in the appropriate * pattern string. It then substitutes all relevant substitutions. * * @param inputTable - Example: { * "singular": "You have a cat in a photo album named {title}", * "plural": "You have cats in a photo album named {title}" * } * -or- * { * "singular": ["You have a cat in a photo album named {title}", <hash>], * "plural": ["You have cats in a photo album named {title}", <hash>] * } * * or table can simply be a pattern string: * "You have a cat in a photo album named {title}" * -or- * ["You have a cat in a photo album named {title}", <hash>] * * @param inputArgs - arguments from which to pull substitutions * Example: [["singular", null], [null, {title: "felines!"}]] * * @param options - options for runtime * translation dictionary access. hk stands for hash key which is used to look * up translated payload in React Native. ehk stands for enum hash key which * contains a structured enums to hash keys map which will later be traversed * to look up enum-less translated payload. */ function fbtCallsite( inputTable: FbtRuntimeInput, inputArgs: ?FbtTableArgs, options: ?FbtInputOpts, ): Fbt { // TODO T61652022: Remove this when no longer used in fbsource // $FlowFixMe[sketchy-null-string] if ((options?.hk || options?.ehk) && jsonExportMode) { /* $FlowFixMe[incompatible-return] : breaking typing because this should * never happen */ return { text: inputTable, fbt: true, hashKey: options.hk, }; } // Adapt the input payload to the translated table and arguments we expect // // WWW: The payload is ready, as-is, and is pre-translated UNLESS we detect // the magic BINAST string which needs to be stripped if it exists. // // RN: we look up our translated table via the hash key (options.hk) and // flattened enum hash key (options.ehk), which partially resolves the // translation for the enums (should they exist). // // OSS: The table is the English payload, and, by default, we lookup the // translated payload via FbtTranslations let {args, table: pattern} = FbtHooks.getTranslatedInput({ table: inputTable, args: inputArgs, options, }); // [fbt_impressions] // If this is a string literal (no tokens to substitute) then 'args' is empty // and the logic will skip the table traversal. // [table traversal] // At this point we assume that table is a hash (possibly nested) that we // need to traverse in order to pick the correct string, based on the // args that follow. let allSubstitutions = {}; if (pattern.__vcg != null) { args = args || []; const {GENDER} = FbtHooks.getViewerContext(); const variation = getGenderVariations(GENDER); args.unshift(FbtTableAccessor.getGenderResult(variation, null, GENDER)); } if (args) { if (typeof pattern !== 'string') { // On mobile, table can be accessed at the native layer when fetching // translations. If pattern is not a string here, table has not been accessed pattern = FbtTable.access(pattern, args, 0); } allSubstitutions = getAllSubstitutions(args); invariant(pattern !== null, 'Table access failed'); } let patternString, patternHash; if (Array.isArray(pattern)) { // [fbt_impressions] // When logging of string impressions is enabled, the string and its hash // are packaged in an array. We want to log the hash patternString = pattern[0]; patternHash = pattern[1]; // Append '1_' for appid's prepended to our i18n hash // (see intl_get_application_id) const stringID = '1_' + patternHash; if (overrides[stringID] != null && overrides[stringID] !== '') { patternString = overrides[stringID]; FbtHooks.onTranslationOverride(patternHash); } FbtHooks.logImpression(patternHash); } else if (typeof pattern === 'string') { patternString = pattern; } else { throw new Error( 'Table access did not result in string: ' + (pattern === undefined ? 'undefined' : JSON.stringify(pattern)) + ', Type: ' + typeof pattern, ); } const cachedFbt = cachedFbtResults[patternString]; const hasSubstitutions = _hasKeys(allSubstitutions); if (cachedFbt && !hasSubstitutions) { return cachedFbt; } else { const fbtContent = substituteTokens(patternString, allSubstitutions); // Use this._wrapContent voluntarily so that it can be overwritten in fbs.js const result = (this._wrapContent: typeof wrapContent)( fbtContent, patternString, patternHash, ); if (!hasSubstitutions) { cachedFbtResults[patternString] = result; } return result; } } function getAllSubstitutions(args) { const allSubstitutions = {}; args.forEach(arg => { const substitution = arg[ARG.SUBSTITUTION]; if (!substitution) { return; } for (const tokenName in substitution) { if (hasOwnProperty.call(substitution, tokenName)) { invariant( allSubstitutions[tokenName] == null, 'Cannot register a substitution with token=`%s` more than once', tokenName, ); allSubstitutions[tokenName] = substitution[tokenName]; } } }); return allSubstitutions; } /** * _hasKeys takes an object and returns whether it has any keys. It purposefully * avoids creating the temporary arrays incurred by calling Object.keys(o) * @param {Object} o - Example: "allSubstitutions" */ function _hasKeys(o) { for (const k in o) { return true; } return false; } /** * fbt._enum() takes an enum value and returns a tuple in the format: * [value, null] * @param value - Example: "id1" * @param range - Example: {"id1": "groups", "id2": "videos", ...} */ function fbtEnum( value: FbtTableKey, range: {[enumKey: string]: string}, ): FbtTableArg { if (__DEV__) { invariant(value in range, 'invalid value: %s', value); } return FbtTableAccessor.getEnumResult(value); } /** * fbt._subject() takes a gender value and returns a tuple in the format: * [variation, null] * @param value - Example: "16777216" */ function fbtSubject(value: GenderConstEnum): FbtTableArg { return FbtTableAccessor.getGenderResult( getGenderVariations(value), null, value, ); } /** * fbt._param() takes a `label` and `value` returns a tuple in the format: * [?variation, {label: "replaces {label} in pattern string"}] * @param label - Example: "label" * @param value * - E.g. 'replaces {label} in pattern' * @param variations Variation type and variation value (if explicitly provided) * E.g. * number: `[0]`, `[0, count]`, or `[0, foo.someNumber() + 1]` * gender: `[1, someGender]` */ function fbtParam( label: string, value: mixed, variations?: | [$PropertyType<ParamVariationType, 'number'>, ?number] | [$PropertyType<ParamVariationType, 'gender'>, GenderConstEnum], ): FbtTableArg { const substitution = {[label]: value}; if (variations) { if (variations[0] === ParamVariation.number) { const number = variations.length > 1 ? variations[1] : value; invariant(typeof number === 'number', 'fbt.param expected number'); const variation = getNumberVariations(number); // this will throw if `number` is invalid if (typeof value === 'number') { substitution[label] = intlNumUtils.formatNumberWithThousandDelimiters(value); } return FbtTableAccessor.getNumberResult(variation, substitution, number); } else if (variations[0] === ParamVariation.gender) { const gender = variations[1]; invariant(gender != null, 'expected gender value'); return FbtTableAccessor.getGenderResult( getGenderVariations(gender), substitution, gender, ); } else { invariant(false, 'Unknown invariant mask'); } } else { return FbtTableAccessor.getSubstitution(substitution); } } /** * fbt._implicitParam() behaves like fbt._param() */ function fbtImplicitParam( label: string, value: mixed, variations?: | [$PropertyType<ParamVariationType, 'number'>, ?number] | [$PropertyType<ParamVariationType, 'gender'>, GenderConstEnum], ): FbtTableArg { return this._param(label, value, variations); } /** * fbt._plural() takes a `count` and 2 optional params: `label` and `value`. * It returns a tuple in the format: * [?variation, {label: "replaces {label} in pattern string"}] * @param count - Example: 2 * @param label * - E.g. 'replaces {number} in pattern' * @param value * - The value to use (instead of count) for replacing {label} */ function fbtPlural(count: number, label: ?string, value?: mixed): FbtTableArg { const variation = getNumberVariations(count); const substitution: {[string]: mixed} = {}; // $FlowFixMe[sketchy-null-string] if (label) { if (typeof value === 'number') { substitution[label] = intlNumUtils.formatNumberWithThousandDelimiters(value); } else { substitution[label] = // $FlowFixMe[sketchy-null-mixed] value || intlNumUtils.formatNumberWithThousandDelimiters(count); } } return FbtTableAccessor.getNumberResult(variation, substitution, count); } /** * fbt._pronoun() takes a 'usage' string and a GenderConst value and returns a tuple in the format: * [variations, null] * @param usage - Example: PronounUsage.object. * @param gender - Example: GenderConst.MALE_SINGULAR * @param options - Example: { human: 1 } */ function fbtPronoun( usage: $Values<typeof ValidPronounUsages>, gender: GenderConstEnum, options: ?{human?: 1}, ): FbtTableArg { invariant( gender !== GenderConst.NOT_A_PERSON || !options || !options.human, 'Gender cannot be GenderConst.NOT_A_PERSON if you set "human" to true', ); const genderKey = getPronounGenderKey(usage, gender); return FbtTableAccessor.getPronounResult(genderKey); } /** * Must match implementation from babel-plugin-fbt/src/fbt-nodes/FbtPronounNode.js */ function getPronounGenderKey(usage, gender) { switch (gender) { case GenderConst.NOT_A_PERSON: return usage === ValidPronounUsages.object || usage === ValidPronounUsages.reflexive ? GenderConst.NOT_A_PERSON : GenderConst.UNKNOWN_PLURAL; case GenderConst.FEMALE_SINGULAR: case GenderConst.FEMALE_SINGULAR_GUESS: return GenderConst.FEMALE_SINGULAR; case GenderConst.MALE_SINGULAR: case GenderConst.MALE_SINGULAR_GUESS: return GenderConst.MALE_SINGULAR; case GenderConst.MIXED_UNKNOWN: case GenderConst.FEMALE_PLURAL: case GenderConst.MALE_PLURAL: case GenderConst.NEUTER_PLURAL: case GenderConst.UNKNOWN_PLURAL: return GenderConst.UNKNOWN_PLURAL; case GenderConst.NEUTER_SINGULAR: case GenderConst.UNKNOWN_SINGULAR: return usage === ValidPronounUsages.reflexive ? GenderConst.NOT_A_PERSON : GenderConst.UNKNOWN_PLURAL; } // Mirrors the behavior of :fbt:pronoun when an unknown gender value is given. return GenderConst.NOT_A_PERSON; } /** * fbt.name() takes a `label`, `value`, and `gender` and * returns a tuple in the format: * [gender, {label: "replaces {label} in pattern string"}] * @param label - Example: "label" * @param value * - E.g. 'replaces {label} in pattern' * @param gender - Example: "IntlVariations.GENDER_FEMALE" */ function fbtName( label: string, value: mixed, gender: GenderConstEnum, ): FbtTableArg { const variation = getGenderVariations(gender); const substitution: {[string]: mixed} = {}; substitution[label] = value; return FbtTableAccessor.getGenderResult(variation, substitution, gender); } function wrapContent( fbtContent: $NestedFbtContentItems | string, translation: PatternString, hash: ?PatternHash, ): Fbt { const contents = typeof fbtContent === 'string' ? [fbtContent] : fbtContent; const errorListener = FbtHooks.getErrorListener({ translation, hash, }); const result = FbtHooks.getFbtResult({ contents, errorListener, patternHash: hash, patternString: translation, }); // $FlowFixMe[incompatible-return] FbtHooks.getFbtResult returns mixed. return result; } function enableJsonExportMode(): void { jsonExportMode = true; } function disableJsonExportMode(): void { jsonExportMode = false; } // Must define this as a standalone function // because Flow doesn't support %check on as a class static method function isFbtInstance(value: mixed): boolean %checks { return value instanceof FbtResultBase; } const fbt = function () {}; fbt._ = fbtCallsite; fbt._enum = fbtEnum; fbt._implicitParam = fbtImplicitParam; fbt._name = fbtName; fbt._param = fbtParam; fbt._plural = fbtPlural; fbt._pronoun = fbtPronoun; fbt._subject = fbtSubject; fbt._wrapContent = wrapContent; fbt.disableJsonExportMode = disableJsonExportMode; fbt.enableJsonExportMode = enableJsonExportMode; fbt.isFbtInstance = isFbtInstance; if (__DEV__) { fbt._getCachedFbt = (s: string): Fbt => cachedFbtResults[s]; } // Use $-FlowFixMe instead of $-FlowExpectedError since fbsource doesn't use the latter module.exports = ((fbt: $FlowFixMe): $FbtFunctionAPI);