Skip to content

Commit

Permalink
stringProperties experimentation
Browse files Browse the repository at this point in the history
  • Loading branch information
jonathanolson committed Aug 13, 2022
1 parent f03f428 commit eee0d53
Show file tree
Hide file tree
Showing 5 changed files with 146 additions and 94 deletions.
189 changes: 105 additions & 84 deletions js/getStringModule.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
* @author Jonathan Olson <jonathan.olson>
*/

import StringProperty from '../../axon/js/StringProperty.js';
import DynamicProperty from '../../axon/js/DynamicProperty.js';
import localeProperty from '../../joist/js/localeProperty.js';
import Tandem from '../../tandem/js/Tandem.js';

// constants
const FALLBACK_LOCALE = 'en';

Expand All @@ -25,86 +30,102 @@ const getStringModule = requirejsNamespace => {
assert && assert( typeof phet.chipper.locale === 'string', 'phet.chipper.locale should have been loaded by now' );
assert && assert( phet.chipper.strings, 'phet.chipper.strings should have been loaded by now' );

const locales = Object.keys( phet.chipper.strings );

const getFallbackLocales = locale => [
locale,
...( locale.includes( '_' ) && !locale.startsWith( FALLBACK_LOCALE ) ? [ locale.slice( 0, 2 ) ] : [] ),
...( locale !== FALLBACK_LOCALE ? [ FALLBACK_LOCALE ] : [] )
].filter( locale => locales.includes( locale ) );

// Construct locales in increasing specificity, e.g. [ 'en', 'zh', 'zh_CN' ], so we get fallbacks in order
const locales = [ FALLBACK_LOCALE ];
// const locales = [ FALLBACK_LOCALE ];
const stringKeyPrefix = `${requirejsNamespace}/`;

// e.g. for zh_CN, we want to push 'zh' (the partial fallback) on
if ( phet.chipper.locale.indexOf( '_' ) >= 0 && phet.chipper.locale.slice( 0, 2 ) !== FALLBACK_LOCALE ) {
locales.push( phet.chipper.locale.slice( 0, 2 ) );
}
// push the full locale if it is NOT the fallback
if ( phet.chipper.locale !== FALLBACK_LOCALE ) {
locales.push( phet.chipper.locale );
}

// consecutively create locale-specific string objects, and merge them into our result
const result = {};
// We may have other older (unused) keys in babel, and we are only doing the search that matters with the English
// string keys.
const allStringKeysInRepo = Object.keys( phet.chipper.strings[ FALLBACK_LOCALE ] ).filter( stringKey => stringKey.indexOf( stringKeyPrefix ) === 0 );

// localePropertiesMap[ locale ][ stringKey ]
const localePropertiesMap = {};
locales.forEach( locale => {
// We may have other older (unused) keys in babel, and we are only doing the search that matters with the English
// string keys.
const assertStructure = locale === FALLBACK_LOCALE;

const partialStringMap = phet.chipper.strings[ locale ];
if ( partialStringMap ) {
// The object where our locale-specific string object is built
const localeObject = {};

const stringKeysInRepo = Object.keys( partialStringMap ).filter( stringKey => stringKey.indexOf( stringKeyPrefix ) === 0 );

// We'll iterate over every string key that has this repo's prefix, so that we can add any relevant information
// for it into the localeObject.
stringKeysInRepo.forEach( stringKey => {
// strip off the requirejsNamespace, e.g. 'JOIST/ResetAllButton.name' => 'ResetAllButton.name'
const stringKeyWithoutPrefix = stringKey.slice( stringKeyPrefix.length );

const keyParts = stringKeyWithoutPrefix.split( '.' );
const lastKeyPart = keyParts[ keyParts.length - 1 ];
const allButLastKeyPart = keyParts.slice( 0, keyParts.length - 1 );

// During traversal into the string object, this will hold the object where the next level needs to be defined,
// whether that's another child object, or the string value itself.
let reference = localeObject;

// We'll traverse down through the parts of a string key (separated by '.'), creating a new level in the
// string object for each one. This is done for all BUT the last part, since we'll want to assign the result
// of that to a raw string value (rather than an object).
let partialKey = stringKeyPrefix;
allButLastKeyPart.forEach( ( keyPart, i ) => {
// When concatenating each level into the final string key, we don't want to put a '.' directly after the
// slash, because `JOIST/.ResetAllButton.name` would be invalid.
// See https://github.com/phetsims/chipper/issues/922
partialKey += `${i > 0 ? '.' : ''}${keyPart}`;

// Don't allow e.g. JOIST/a and JOIST/a.b, since localeObject.a would need to be a string AND an object at the
// same time.
assert && assert( !assertStructure || typeof reference[ keyPart ] !== 'string',
'It is not allowed to have two different string keys where one is extended by adding a period (.) at the end ' +
`of the other. The string key ${partialKey} is extended by ${stringKey} in this case, and should be changed.` );

// Create the next nested level, and move into it
if ( !reference[ keyPart ] ) {
reference[ keyPart ] = {};
}
reference = reference[ keyPart ];
} );

assert && assert( !assertStructure || typeof reference[ lastKeyPart ] !== 'object',
'It is not allowed to have two different string keys where one is extended by adding a period (.) at the end ' +
`of the other. The string key ${stringKey} is extended by another key, something containing ${reference[ lastKeyPart ] && Object.keys( reference[ lastKeyPart ] )}.` );
assert && assert( !assertStructure || !reference[ lastKeyPart ],
`We should not have defined this place in the object (${stringKey}), otherwise it means a duplicated string key OR extended string key` );

// In case our assertions are not enabled, we'll need to proceed without failing out (so we allow for the
// extended string keys in our actual code, even though assertions should prevent that).
if ( typeof reference !== 'string' ) {
reference[ lastKeyPart ] = phet.chipper.mapString( partialStringMap[ stringKey ] );
localePropertiesMap[ locale ] = {};

const fallbackLocales = getFallbackLocales( locale );

allStringKeysInRepo.forEach( stringKey => {
let string = null;
fallbackLocales.forEach( fallbackLocale => {
if ( string === null && typeof phet.chipper.strings[ fallbackLocale ][ stringKey ] === 'string' ) {
string = phet.chipper.strings[ fallbackLocale ][ stringKey ];
}
} );
const sanitizedStringKey = stringKey
.replace( /_/g, ',' )
.replace( /\./g, ',' )
.replace( /-/g, ',' )
.replace( /\//g, ',' );
localePropertiesMap[ locale ][ stringKey ] = new StringProperty( string, {
tandem: Tandem.GENERAL_VIEW.createTandem( 'strings' ).createTandem( locale.replace( '_', ',' ) ).createTandem( `${sanitizedStringKey}Property` )
} );
} );
} );

// Combine the strings together, overriding any more "default" string values with their more specific translated
// values.
_.merge( result, localeObject );
const stringModule = {};

allStringKeysInRepo.forEach( stringKey => {
// strip off the requirejsNamespace, e.g. 'JOIST/ResetAllButton.name' => 'ResetAllButton.name'
const stringKeyWithoutPrefix = stringKey.slice( stringKeyPrefix.length );

const keyParts = stringKeyWithoutPrefix.split( '.' );
const lastKeyPart = keyParts[ keyParts.length - 1 ];
const allButLastKeyPart = keyParts.slice( 0, keyParts.length - 1 );

// During traversal into the string object, this will hold the object where the next level needs to be defined,
// whether that's another child object, or the string value itself.
let reference = stringModule;

// We'll traverse down through the parts of a string key (separated by '.'), creating a new level in the
// string object for each one. This is done for all BUT the last part, since we'll want to assign the result
// of that to a raw string value (rather than an object).
let partialKey = stringKeyPrefix;
allButLastKeyPart.forEach( ( keyPart, i ) => {
// When concatenating each level into the final string key, we don't want to put a '.' directly after the
// slash, because `JOIST/.ResetAllButton.name` would be invalid.
// See https://github.com/phetsims/chipper/issues/922
partialKey += `${i > 0 ? '.' : ''}${keyPart}`;

// Don't allow e.g. JOIST/a and JOIST/a.b, since localeObject.a would need to be a string AND an object at the
// same time.
assert && assert( typeof reference[ keyPart ] !== 'string',
'It is not allowed to have two different string keys where one is extended by adding a period (.) at the end ' +
`of the other. The string key ${partialKey} is extended by ${stringKey} in this case, and should be changed.` );

// Create the next nested level, and move into it
if ( !reference[ keyPart ] ) {
reference[ keyPart ] = {};
}
reference = reference[ keyPart ];
} );

assert && assert( typeof reference[ lastKeyPart ] !== 'object',
'It is not allowed to have two different string keys where one is extended by adding a period (.) at the end ' +
`of the other. The string key ${stringKey} is extended by another key, something containing ${reference[ lastKeyPart ] && Object.keys( reference[ lastKeyPart ] )}.` );
assert && assert( !reference[ lastKeyPart ],
`We should not have defined this place in the object (${stringKey}), otherwise it means a duplicated string key OR extended string key` );

// In case our assertions are not enabled, we'll need to proceed without failing out (so we allow for the
// extended string keys in our actual code, even though assertions should prevent that).
if ( typeof reference !== 'string' ) {
const dynamicProperty = new DynamicProperty( localeProperty, {
derive: locale => localePropertiesMap[ locale ][ stringKey ],
bidirectional: true
} );

reference[ `${lastKeyPart}Property` ] = dynamicProperty;
dynamicProperty.link( string => {
reference[ lastKeyPart ] = string;
} );
}
} );

Expand All @@ -116,20 +137,20 @@ const getStringModule = requirejsNamespace => {
* @param {string} partialKey - e.g 'ResetAllButton.name' for the string key 'SCENERY_PHET/ResetAllButton.name'
* @returns {string}
*/
result.get = partialKey => {
stringModule.get = partialKey => {
const fullKey = `${requirejsNamespace}/${partialKey}`;

// Iterate locales backwards for the lookup, so we get the most specific string first.
for ( let i = locales.length - 1; i >= 0; i-- ) {
const map = phet.chipper.strings[ locales[ i ] ];
if ( map && map[ fullKey ] ) {
return map[ fullKey ];
}
}
const property = localePropertiesMap[ localeProperty.value ][ fullKey ];

throw new Error( `Unable to find string ${fullKey} in get( '${partialKey}' )` );
if ( property ) {
return property.value;
}
else {
throw new Error( `Unable to find string ${fullKey} in get( '${partialKey}' )` );
}
};

return result;
return stringModule;
};
export default getStringModule;

export default getStringModule;
7 changes: 4 additions & 3 deletions js/grunt/getStringMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -215,9 +215,10 @@ module.exports = function( mainRepo, locales, phetLibs, usedModules ) {
}
}
}
assert( stringValue !== null, `Missing string information for ${repo} ${partialStringKey}` );

stringMap[ locale ][ `${requirejsNamespaceMap[ repo ]}/${partialStringKey}` ] = stringValue;
if ( !partialStringKey.endsWith( 'Property' ) ) {
assert( stringValue !== null, `Missing string information for ${repo} ${partialStringKey}` );
stringMap[ locale ][ `${requirejsNamespaceMap[ repo ]}/${partialStringKey}` ] = stringValue;
}
} );
} );
} );
Expand Down
4 changes: 4 additions & 0 deletions js/grunt/modulify.js
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ const createStringModule = async repo => {
*/
/* eslint-disable */
import getStringModule from '../../chipper/js/getStringModule.js';
import Property from '../../axon/js/Property.js';
import ${namespace} from './${namespace}.js';
type StringsType = ${getStringTypes( repo )};
Expand Down Expand Up @@ -370,6 +371,7 @@ const getStringTypes = repo => {

if ( k === path.length - 1 && m === tokens.length - 1 ) {
level[ token ] = '{{STRING}}'; // instead of value = allElement.value
level[ `${token}Property` ] = '{{STRING_PROPERTY}}';
}
else {
level[ token ] = level[ token ] || {};
Expand All @@ -385,9 +387,11 @@ const getStringTypes = repo => {
text = replace( text, '"', '\'' );

text = replace( text, '\'{{STRING}}\'', 'string' );
text = replace( text, '\'{{STRING_PROPERTY}}\'', 'Property<string>' );

// Add ; to the last in the list
text = replace( text, ': string\n', ': string;\n' );
text = replace( text, ': Property<string>\n', ': Property<string>;\n' );

// Use ; instead of ,
text = replace( text, ',', ';' );
Expand Down
11 changes: 11 additions & 0 deletions js/initialize-globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,17 @@
public: false
},

/**
* Provides the (extra) locales to load during startup for an un-built simulation
*/
loadExtraLocales: {
type: 'array',
elementSchema: {
type: 'string'
},
defaultValue: []
},

/**
* Select the language of the sim to the specific locale. Default to "en".
* @memberOf PhetQueryParameters
Expand Down
29 changes: 22 additions & 7 deletions js/load-unbuilt-strings.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,29 @@
window.phet.chipper.loadModules();
};

const locales = [ FALLBACK_LOCALE ];

// We don't use QueryStringMachine, because we are loaded first.
const customLocale = new window.URLSearchParams( window.location.search ).get( 'locale' );
const loadCustomLocale = customLocale && customLocale !== FALLBACK_LOCALE;
const locales = [
FALLBACK_LOCALE,
...( loadCustomLocale ? [ customLocale ] : [] ), // e.g. 'zh_CN'
...( ( loadCustomLocale && customLocale.length > 2 && customLocale.slice( 0, 2 ) !== FALLBACK_LOCALE ) ? [ customLocale.slice( 0, 2 ) ] : [] ) // e.g. 'zh'
];
const localeQueryParam = new window.URLSearchParams( window.location.search ).get( 'locale' );
const loadExtraLocalesQueryParam = new window.URLSearchParams( window.location.search ).get( 'loadExtraLocales' );

// Load other locales we might potentially need (keeping out duplicates)
[
localeQueryParam,
...( loadExtraLocalesQueryParam ? loadExtraLocalesQueryParam.split( ',' ) : [] )
].forEach( locale => {
if ( locale ) {
// e.g. 'zh_CN'
if ( !locales.includes( locale ) ) {
locales.push( locale );
}
// e.g. 'zh'
const shortLocale = locale.slice( 0, 2 );
if ( locale.length > 2 && !locales.includes( shortLocale ) ) {
locales.push( shortLocale );
}
}
} );

phet.chipper.stringRepos.forEach( stringRepoData => {
const repo = stringRepoData.repo;
Expand Down

1 comment on commit eee0d53

@jonathanolson
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.