packages/babel-plugin-fbt/src/JSFbtBuilder.js (228 lines of code) (raw):

/** * (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary. * * @emails oncall+i18n_fbt_js * @flow strict-local * @format */ /* eslint max-len: ["warn", 120] */ 'use strict'; import type {AnyStringVariationArg} from './fbt-nodes/FbtArguments'; import type {EnumKey} from './FbtEnumRegistrar'; import type {GenderConstEnum} from './Gender'; import type {JSFBTMetaEntry} from './index'; const { EnumStringVariationArg, GenderStringVariationArg, NumberStringVariationArg, } = require('./fbt-nodes/FbtArguments'); const FbtElementNode = require('./fbt-nodes/FbtElementNode'); const FbtEnumNode = require('./fbt-nodes/FbtEnumNode'); const FbtImplicitParamNode = require('./fbt-nodes/FbtImplicitParamNode'); const FbtNameNode = require('./fbt-nodes/FbtNameNode'); const FbtParamNode = require('./fbt-nodes/FbtParamNode'); const FbtPluralNode = require('./fbt-nodes/FbtPluralNode'); const FbtPronounNode = require('./fbt-nodes/FbtPronounNode'); const {ShowCountKeys} = require('./FbtConstants'); const {varDump} = require('./FbtUtil'); const { EXACTLY_ONE, FbtVariationType, GENDER_ANY, NUMBER_ANY, SUBJECT, } = require('./translate/IntlVariations'); const invariant = require('invariant'); const nullthrows = require('nullthrows'); /** * Helper class to assemble the JSFBT table data. * It's responsible for: * - producing all the combinations of string variations' candidate values, * from a given list of string variation arguments. * - generating metadata to describe the meaning of each level of the JSFBT table tree. */ class JSFbtBuilder { /** * Source code that matches the Babel nodes used in the provided `stringVariationArgs` */ +fileSource: string; /** * Map of fbt:enum at the current recursion level of `_getStringVariationCombinations()` */ +usedEnums: {[enumArgCode: string]: EnumKey}; /** * Map of fbt:plural at the current recursion level of `_getStringVariationCombinations()` */ +usedPlurals: { [pluralsArgCode: string]: typeof EXACTLY_ONE | typeof NUMBER_ANY, }; /** * Map of fbt:pronoun at the current recursion level of `_getStringVariationCombinations()` */ +usedPronouns: { [pronounsArgCode: string]: GenderConstEnum | typeof GENDER_ANY, }; /** * Set this to `true` if we're extracting strings for React Native */ +reactNativeMode: boolean; /** * List of string variation arguments from a given fbt callsite */ +stringVariationArgs: $ReadOnlyArray<AnyStringVariationArg>; constructor( fileSource: string, stringVariationArgs: $ReadOnlyArray<AnyStringVariationArg>, reactNativeMode?: boolean, ): void { this.fileSource = fileSource; this.reactNativeMode = !!reactNativeMode; this.stringVariationArgs = stringVariationArgs; this.usedEnums = {}; this.usedPlurals = {}; this.usedPronouns = {}; } /** * Generates a list of metadata entries that describe the usage of each level * of the JSFBT table tree * @param compactStringVariationArgs Consolidated list of string variation arguments. * See FbtFunctionCallProcessor#_compactStringVariationArgs() */ buildMetadata( compactStringVariationArgs: $ReadOnlyArray<AnyStringVariationArg>, ): Array<?JSFBTMetaEntry> { return compactStringVariationArgs.map(svArg => { const {fbtNode} = svArg; if (fbtNode instanceof FbtPluralNode) { if (fbtNode.options.showCount !== ShowCountKeys.no) { return { token: nullthrows(fbtNode.options.name), type: FbtVariationType.NUMBER, singular: true, }; } else { return this.reactNativeMode ? {type: FbtVariationType.NUMBER} : null; } } if ( fbtNode instanceof FbtElementNode || fbtNode instanceof FbtImplicitParamNode ) { return { token: SUBJECT, type: FbtVariationType.GENDER, }; } if (fbtNode instanceof FbtPronounNode) { return this.reactNativeMode ? {type: FbtVariationType.PRONOUN} : null; } if (svArg instanceof EnumStringVariationArg) { invariant( fbtNode instanceof FbtEnumNode, 'Expected fbtNode to be an instance of FbtEnumNode but got `%s` instead', fbtNode.constructor.name || varDump(fbtNode), ); // We ensure we have placeholders in our metadata because enums and // pronouns don't have metadata and will add "levels" to our resulting // table. // // Example for the code: // // fbt.enum(value, { // groups: 'Groups', // photos: 'Photos', // videos: 'Videos', // }) // // Expected metadata entry: // for non-RN -> `null` // for RN -> `{range: ['groups', 'photos', 'videos']}` return this.reactNativeMode ? // Enum range will later be used to extract enums from the payload for React Native {range: Object.keys(fbtNode.options.range)} : null; } if ( svArg instanceof GenderStringVariationArg || svArg instanceof NumberStringVariationArg ) { invariant( fbtNode instanceof FbtNameNode || fbtNode instanceof FbtParamNode, 'Expected fbtNode to be an instance of FbtNameNode or FbtParamNode but got `%s` instead', fbtNode.constructor.name || varDump(fbtNode), ); return svArg instanceof NumberStringVariationArg ? { token: fbtNode.options.name, type: FbtVariationType.NUMBER, } : { token: fbtNode.options.name, type: FbtVariationType.GENDER, }; } invariant( false, 'Unsupported string variation argument: %s', varDump(svArg), ); }); } /** * Get all the string variation combinations derived from a list of string variation arguments. * * E.g. If we have a list of string variation arguments as: * * [genderSV, numberSV] * * Assuming genderSV produces candidate variation values as: male, female, unknown * Assuming numberSV produces candidate variation values as: singular, plural * * The output would be: * * [ * [ genderSV(male), numberSV(singular) ], * [ genderSV(male), numberSV(plural) ], * [ genderSV(female), numberSV(singular) ], * [ genderSV(female), numberSV(plural) ], * [ genderSV(unknown), numberSV(singular) ], * [ genderSV(unknown), numberSV(plural) ], * ] * * Follows legacy behavior: * - process each SV argument (FIFO), * - for each SV argument of the same fbt construct (e.g. plural) * (and not of the same variation type like Gender) * - check if there's already an existing SV argument of the same JS code being used * - if so, re-use the same variation value * - else, "multiplex" new variation value * Do this for plural, gender, enum */ getStringVariationCombinations(): $ReadOnlyArray< $ReadOnlyArray<AnyStringVariationArg>, > { return this._getStringVariationCombinations(); } _getStringVariationCombinations( combos: Array<$ReadOnlyArray<AnyStringVariationArg>> = [], curArgIndex: number = 0, prevArgs: $ReadOnlyArray<AnyStringVariationArg> = [], ): Array<$ReadOnlyArray<AnyStringVariationArg>> { invariant( curArgIndex >= 0, 'curArgIndex value must greater or equal to 0, but we got `%s` instead', curArgIndex, ); if (this.stringVariationArgs.length === 0) { return combos; } if (curArgIndex >= this.stringVariationArgs.length) { combos.push(prevArgs); return combos; } const curArg = this.stringVariationArgs[curArgIndex]; const {fbtNode} = curArg; const {usedEnums, usedPlurals, usedPronouns} = this; const recurse = <V>( candidateValues: $ReadOnlyArray<V>, beforeRecurse?: V => mixed, isCollapsible: boolean = false, ): void => candidateValues.forEach(value => { if (beforeRecurse) { beforeRecurse(value); } this._getStringVariationCombinations( combos, curArgIndex + 1, prevArgs.concat( curArg.cloneWithValue( // $FlowFixMe[incompatible-call] `value` should be compatible with cloneWithValue() value, isCollapsible, ), ), ); }); if (fbtNode instanceof FbtEnumNode) { invariant( curArg instanceof EnumStringVariationArg, 'Expected EnumStringVariationArg but got: %s', varDump(curArg), ); const argCode = curArg.getArgCode(this.fileSource); if (argCode in usedEnums) { const enumKey = usedEnums[argCode]; invariant( enumKey in fbtNode.options.range, '%s not found in %s. Attempting to re-use incompatible enums', enumKey, varDump(fbtNode.options.range), ); recurse([enumKey], undefined, true); return combos; } recurse(curArg.candidateValues, value => (usedEnums[argCode] = value)); delete usedEnums[argCode]; } else if (fbtNode instanceof FbtPluralNode) { invariant( curArg instanceof NumberStringVariationArg, 'Expected NumberStringVariationArg but got: %s', varDump(curArg), ); const argCode = curArg.getArgCode(this.fileSource); if (argCode in usedPlurals) { // Constrain our plural value ('many'/'singular') BUT still add a // single level. We don't currently prune runtime args like we do // with enums, but we ought to... // TODO(T41100260) Prune plurals better recurse([usedPlurals[argCode]]); return combos; } recurse(curArg.candidateValues, value => (usedPlurals[argCode] = value)); delete usedPlurals[argCode]; } else if (fbtNode instanceof FbtPronounNode) { invariant( curArg instanceof GenderStringVariationArg, 'Expected GenderStringVariationArg but got: %s', varDump(curArg), ); const argCode = curArg.getArgCode(this.fileSource); if (argCode in usedPronouns) { // Constrain our pronoun value BUT still add a // single level. We don't currently prune runtime args like we do // with enums, but we ought to... // TODO(T82185334) Prune pronouns better recurse([usedPronouns[argCode]]); return combos; } recurse(curArg.candidateValues, value => (usedPronouns[argCode] = value)); delete usedPronouns[argCode]; } else if ( curArg instanceof NumberStringVariationArg || curArg instanceof GenderStringVariationArg ) { recurse( curArg.candidateValues, undefined, curArg instanceof GenderStringVariationArg && fbtNode instanceof FbtImplicitParamNode, ); } else { invariant( false, 'Unsupported string variation argument: %s', varDump(curArg), ); } return combos; } } module.exports = JSFbtBuilder;