packages/babel-plugin-fbt/src/translate/TranslationBuilder.js (349 lines of code) (raw):
/**
* (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary.
*
* @emails oncall+i18n_fbt_js
* @flow strict-local
* @format
*/
'use strict';
import type {
FbtTableKey,
PatternHash,
} from '../../../../runtime/shared/FbtTable';
import type {FbtSiteHashifiedTableJSFBTTree} from './FbtSiteBase';
import type {
IntlVariationMaskValue,
IntlVariationsEnum,
} from './IntlVariations';
import type TranslationConfig from './TranslationConfig';
import type {ConstraintKey} from './VariationConstraintUtils';
const {hasKeys, varDump} = require('../FbtUtil');
const {replaceClearTokensWithTokenAliases} = require('../FbtUtil');
const {FbtSite, FbtSiteMetaEntry} = require('./FbtSite');
const IntlVariations = require('./IntlVariations');
const TranslationData = require('./TranslationData');
const {buildConstraintKey} = require('./VariationConstraintUtils');
const invariant = require('invariant');
const nullthrows = require('nullthrows');
const {EXACTLY_ONE, Mask, isValidValue} = IntlVariations;
/**
* Map from a string's hash to its translation payload.
* If the translation is string type, it implies it was machine generatd.
*/
type HashToTranslation = {|[hash: PatternHash]: ?(TranslationData | string)|};
/**
* Leaf can be either a string translation or
* a tuple of translation and hash if `inclHash` is true
*/
type TranslationLeaf = ?string | [?string, PatternHash];
type TranslationTree =
| {|[key: FbtTableKey]: TranslationTree|}
| TranslationLeaf;
/**
* Need to add a `__vcg` field to TranslationTree when the string has a hidden
* viewer gender token
*/
export type TranslationResult =
| TranslationTree
| {|[key: FbtTableKey]: TranslationTree, __vcg: number|};
type MetadataToken = string;
/** e.g. {'name' => IntlGenderVariations.MALE} */
type TokenToConstraint = {|[token: MetadataToken]: IntlVariationsEnum|};
/** e.g. {'name' => IntlVariationMask.GENDER} */
type TokenToMask = {|[token: MetadataToken]: IntlVariationMaskValue|};
/**
* e.g. [['user', 2], ['count', 24]]
* Ideally the type of constraint should be IntlVariationsEnum (number).
* However, because a TranslationData's `variation` property might be in string
* format, let's allow a constraint to be string type for now.
*/
export type TokenConstraintPairs = Array<[MetadataToken, number | string]>;
/** e.g. 'user%2:count%24' => 'this is a translation string' */
type ConstraintKeyToTranslation = {[constraint: ConstraintKey]: string};
/**
* Given an FbtSite (source payload) and the relevant translations,
* builds the corresponding translated payload
*/
class TranslationBuilder {
+_config: TranslationConfig;
+_fbtSite: FbtSite;
/** Memoized function that returns the constraint to translation map for a hash */
+_getConstraintMapWithMemoization: (
hash: PatternHash,
) => ConstraintKeyToTranslation;
+_hasTranslations: boolean;
+_hasVCGenderVariation: boolean;
+_inclHash: boolean;
+_metadata: $ReadOnlyArray<?FbtSiteMetaEntry>;
+_tableOrHash: FbtSiteHashifiedTableJSFBTTree;
+_tokenToMask: TokenToMask;
+_translations: HashToTranslation;
/**
* @param translations Hash of a string to its translation
* @param config Configuration for variation defaults (number/gender)
* @param fbtSite Representation of the <fbt> or fbt() to be translated
* @param inclHash Include hash/identifer in leaf of payloads
*/
constructor(
translations: HashToTranslation,
config: TranslationConfig,
fbtSite: FbtSite,
inclHash: boolean,
) {
this._translations = translations;
this._config = config;
this._fbtSite = fbtSite;
this._tokenToMask = {};
this._metadata = fbtSite.getMetadata();
this._tableOrHash = fbtSite.getTableOrHash();
this._hasVCGenderVariation = this._findVCGenderVariation();
this._hasTranslations = this._translationsExist();
this._getConstraintMapWithMemoization =
_createMemoizedConstraintMapGetter(this);
this._inclHash = inclHash;
// If a gender variation exists, add it to our table
if (this._hasVCGenderVariation) {
this._tableOrHash = {'*': this._tableOrHash};
this._metadata = [
FbtSiteMetaEntry.wrap({
token: IntlVariations.VIEWING_USER,
type: IntlVariations.FbtVariationType.GENDER,
}),
...this._metadata,
];
}
for (let ii = 0; ii < this._metadata.length; ++ii) {
const metadata = this._metadata[ii];
if (metadata != null && metadata.hasVariationMask()) {
const token = nullthrows(
metadata.getToken(),
'Expect `token` to not be null as the metadata has variation mask.',
);
this._tokenToMask[token] = nullthrows(
metadata.getVariationMask(),
'Expect `metadata.getVariationMask()` to be nonnull because ' +
'`metadata.hasVariationMask() === true`.',
);
}
}
}
hasTranslations(): boolean {
return this._hasTranslations;
}
build(): TranslationResult {
const table = this._buildRecursive(this._tableOrHash);
if (this._hasVCGenderVariation) {
invariant(
table != null && typeof table !== 'string' && !Array.isArray(table),
'Expect `table` to not be a TranslationLeaf when ' +
'the string has a hidden viewer context token.',
);
// This hidden key is checked during JS fbt runtime to signal that we
// should access the first entry of our table with the viewer's gender
return {...table, __vcg: 1};
}
return table;
}
_translationsExist(): boolean {
for (const hash in this._fbtSite.getHashToLeaf()) {
const transData = this._translations[hash];
if (
typeof transData === 'string' ||
(transData instanceof TranslationData && transData.hasTranslation())
) {
// There is a translation or simple string for generated translation
return true;
}
}
return false;
}
/**
* Inspect all translation variations for a hidden viewer context token
*/
_findVCGenderVariation(): boolean {
for (const hash in this._fbtSite.getHashToLeaf()) {
const transData = this._translations[hash];
if (!(transData instanceof TranslationData)) {
continue;
}
for (const token of transData.tokens) {
if (token === IntlVariations.VIEWING_USER) {
return true;
}
}
}
return false;
}
/**
* Given a hash (or hash-table), return the translated text (or table of
* texts). If the hash (or hashes) do not have a translation, then the
* original text will be used as the translation.
*
* If we should include the string hash then the method returns a vector with
* [string, hash] so that the hash is available to the run-time logging code.
*/
_buildRecursive(
hashOrTable: FbtSiteHashifiedTableJSFBTTree,
tokenConstraints: TokenToConstraint = {},
levelIdx: number = 0,
): TranslationTree {
if (typeof hashOrTable === 'string') {
return this._getLeafTranslation(hashOrTable, tokenConstraints);
}
const table = {};
for (const key in hashOrTable) {
const branchOrLeaf = hashOrTable[key];
let trans: TranslationTree = this._buildRecursive(
branchOrLeaf,
tokenConstraints,
levelIdx + 1,
);
if (_shouldStore(trans)) {
table[key] = trans;
}
// This level will have metadata if it could potentially have variations.
// Below, we fill the table with those variation entries.
//
// NOTE: A key of '_1' (EXACTLY_ONE) will be processed by the
// buildRecursive call above, as its corresponding token constraint is
// defaulted to '*'. See _getConstraintMap for more details
const metadata = this._metadata[levelIdx];
if (
metadata != null &&
metadata.hasVariationMask() &&
key !== EXACTLY_ONE
) {
const mask = nullthrows(
metadata.getVariationMask(),
'Expect mask not to be null because metadata.hasVariationMask() returns true.',
);
invariant(
mask === Mask.NUMBER || mask === Mask.GENDER,
'Unknown variation mask: %s (%s)',
varDump(mask),
typeof mask,
);
invariant(
isValidValue(key),
'Expect variation keys to be coercible to IntlVariationsEnum: current key=%s (%s)',
varDump(key),
typeof key,
);
const token = nullthrows(
metadata.getToken(),
'Expect `token` to not be falsy when the metadata has a variation mask.',
);
const variationCandidates = _getTypesFromMask(mask);
variationCandidates.forEach(variationKey => {
tokenConstraints[token] = variationKey;
trans = this._buildRecursive(
branchOrLeaf,
tokenConstraints,
levelIdx + 1,
);
if (_shouldStore(trans)) {
table[String(variationKey)] = trans;
}
});
delete tokenConstraints[token];
}
}
return table;
}
_getLeafTranslation(
hash: PatternHash,
tokenConstraints: TokenToConstraint = {},
): TranslationLeaf {
let translation;
const transData: ?TranslationData | string = this._translations[hash];
if (typeof transData === 'string') {
// Fake translations are just simple strings. There's no such thing as
// variation support for these locales. So if token constraints were
// specified, return null and rely on runtime fallback to wildcard.
translation = tokenConstraints ? null : transData;
} else {
if (hasKeys(tokenConstraints)) {
translation = this.getConstrainedTranslation(hash, tokenConstraints);
} else {
// Real translations are TranslationData objects, so we call the
// getDefaultTranslation() method to get the translation (we hope)
const defaultTranslation =
transData && transData.getDefaultTranslation(this._config);
// If no translation available, use the English source text
translation =
defaultTranslation ?? this._fbtSite.getHashToLeaf()[hash].text;
}
}
// Replace clear tokens with their token aliases
if (translation != null) {
translation = replaceClearTokensWithTokenAliases(
translation,
this._fbtSite.getHashToTokenAliases()[hash],
);
}
// Couple the string with a hash if it was marked as such. We do this
// when logging impressions or when using QuickTranslations. The logging
// is performed by `fbt._(...)`
return this._inclHash ? [translation, hash] : translation;
}
/**
* Given a hash and restraints on the token variations, retrieve the
* appropriate translation for our map. A null entry is a signal
* not to add the translation to the map, because it's already in
* the map via its fallback ('*') keys.
*/
getConstrainedTranslation(
hash: PatternHash,
tokenConstraints: TokenToConstraint,
): ?string {
const constraintKeys = [];
for (const token in this._tokenToMask) {
constraintKeys.push([token, tokenConstraints[token] || '*']);
}
const constraintMap = this._getConstraintMapWithMemoization(hash);
const aggregateKey = buildConstraintKey(constraintKeys);
const translation = constraintMap[aggregateKey];
if (!translation) {
return null;
}
for (let ii = 0; ii < constraintKeys.length; ++ii) {
const [token, constraint] = constraintKeys[ii];
if (constraint === '*') {
continue;
}
// If any of the constraints share the same translation as the wildcard
// (default) entry at this level, don't add an entry to the table. They
// will be in the table under the '*' key.
constraintKeys[ii] = [token, '*'];
const wildKey = buildConstraintKey(constraintKeys);
const wildTranslation = constraintMap[wildKey];
if (wildTranslation === translation) {
return null;
}
// Set the constraint back
constraintKeys[ii] = [token, constraint];
}
return translation;
}
_insertConstraint(
constraintKeys: TokenConstraintPairs,
constraintMap: ConstraintKeyToTranslation,
translation: string,
defaultingLevel: number,
) {
const aggregateKey = buildConstraintKey(constraintKeys);
if (constraintMap[aggregateKey]) {
const err = new Error(
'Unexpected duplicate key: ' +
aggregateKey +
'\nOriginal: ' +
constraintMap[aggregateKey] +
'\nNew ' +
translation,
);
err.stack;
throw err;
}
constraintMap[aggregateKey] = translation;
// Also include duplicate '*' entries if it is a default value
for (let ii = defaultingLevel; ii < constraintKeys.length; ii++) {
const [token, val] = constraintKeys[ii];
if (val !== '*' && this._config.isDefaultVariation(val)) {
constraintKeys[ii] = [token, '*'];
this._insertConstraint(
constraintKeys,
constraintMap,
translation,
ii + 1,
);
constraintKeys[ii] = [token, val]; // return the value back
}
}
}
}
/**
* Populates our variation constraint map. The map is of all possible
* variation combinations (serialized as a string) to the appropriate
* translation. For example, JavaScript like:
*
* fbt('Hi ' + fbt.param('user', viewer.name, {gender: viewer.gender}) +
* ', would you like to play ' +
* fbt.param('count', gameCount, {number: true}) +
* ' games of ' + fbt.enum(game,['chess','backgammon','poker']) +
* '? Click ' + fbt.param('link', <Link />), 'sample'),
*
* will have variations for the 'user' and 'count' parameters. Accounting for
* all variations in a locale where we don't merge unknown gender into male
* and we have the dual number variation, the map will have the following keys
* mapping to the corresponding translation.
*
* user%*:count%* [default (unknown) - default (other) ]
* user%*:count%4 [default - one ]
* user%*:count%20 [default - few ]
* user%*:count%24 [default - other ]
* user%1:count%* [male - default (other) ]
* user%1:count%4 [male - one ]
* user%1:count%20 [male - few ]
* user%1:count%24 [male - other ]
* user%2:count%* [female - default (other) ]
* user%2:count%4 [female - singular ]
* user%2:count%20 [female - few ]
* user%2:count%24 [female - other ]
* user%3:count%* [unknown gender - default (other) ]
* user%3:count%4 [unknown gender - singular ]
* user%3:count%20 [unknown gender - few ]
* user%3:count%24 [unknown gender - other ]
*
* Note we have duplicate translations in this map. As an example, the
* following keys map to the same translation
* 'user%*:count%*' (default - default)
* 'user%3:count%*' (unknown - default)
* 'user%3:count%24' (unknown - other)
*
* These translations are deduped later in getConstrainedTranslation such
* that only the 'user%*:count%*' in our tree is in the JSON map. i.e.
*
* {
* // No unknown gender entry exists at this level - we rely on fallback
* '*' => {
* // no plural entry exists at this level
* '*' => {translation},
* ...
*
* },
* ...
* }
*/
function _createMemoizedConstraintMapGetter(
instance: TranslationBuilder,
): (hash: PatternHash) => ConstraintKeyToTranslation {
// Yes this is hand-rolled memoization :(
// TODO: T37795723 - Pull in a lightweight (not bloated) memoization library
const _mem = {};
return function getConstraintMap(
hash: PatternHash,
): ConstraintKeyToTranslation {
if (_mem[hash]) {
return _mem[hash];
}
const constraintMap = {};
const transData = this._translations[hash];
if (transData == null || typeof transData === 'string') {
// No translation? No constraints.
return (_mem[hash] = constraintMap);
}
// For every possible variation combination, create a mapping to its
// corresponding translation
transData.translations.forEach(translation => {
const constraints = {};
for (const idx in translation.variations) {
const variation = translation.variations[idx];
// We prune entries that contain non-default variations
// for tokens we haven't specified.
const token = transData.tokens[Number(idx)];
if (
// Token variation type not specified
!this._tokenToMask[token] ||
// Translated variation type is different than token variation type
this._tokenToMask[token] !== transData.types[Number(idx)]
) {
// Only add default tokens we haven't specified.
if (!this._config.isDefaultVariation(variation)) {
return;
}
}
constraints[token] = variation;
}
// A note about fbt:plurals. They can introduce global token
// discrepancies between leaf nodes. Singular translations don't have
// number tokens, but their plural counterparts can (when showCount =
// "ifMany" or "yes"). If we are dealing with the singular leaf of an
// fbt:plural, since it has a unique hash, we can let it masquerade as
// default: '*', since no such variation actually exists for a
// non-existent token
const constraintKeys = [];
for (const k in this._tokenToMask) {
constraintKeys.push([k, constraints[k] || '*']);
}
this._insertConstraint(
constraintKeys,
constraintMap,
translation.translation,
0,
);
});
return (_mem[hash] = constraintMap);
}.bind(instance);
}
function _shouldStore(branch: TranslationTree): boolean %checks {
return (
branch != null &&
(typeof branch === 'string' || Array.isArray(branch) || hasKeys(branch))
);
}
const G = IntlVariations.Gender;
const _intlVariationGenders = [G.MALE, G.FEMALE, G.UNKNOWN];
const _intlVariationNumbers = [];
for (const k in IntlVariations.Number) {
_intlVariationNumbers.push(IntlVariations.Number[k]);
}
function _getTypesFromMask(
mask: IntlVariationMaskValue,
): $ReadOnlyArray<IntlVariationsEnum> {
const type = IntlVariations.getType(mask);
if (type === Mask.NUMBER) {
return _intlVariationNumbers;
} else {
return _intlVariationGenders;
}
}
module.exports = TranslationBuilder;