Skip to content

Commit

Permalink
Replacing localeProperty validValues with availableRuntimeLocales, ad…
Browse files Browse the repository at this point in the history
…ding duplicated initialize-globals logic, see #970
  • Loading branch information
jonathanolson committed Jun 12, 2024
1 parent 008834e commit 3303641
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 38 deletions.
169 changes: 140 additions & 29 deletions js/i18n/localeProperty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,53 +12,164 @@ import { globalKeyStateTracker, KeyboardUtils } from '../../../scenery/js/import
import Tandem from '../../../tandem/js/Tandem.js';
import StringIO from '../../../tandem/js/types/StringIO.js';
import joist from '../joist.js';
import { ReadOnlyPropertyState } from '../../../axon/js/ReadOnlyProperty.js';

const FALLBACK_LOCALE = 'en';

export type Locale = string;

assert && assert( phet.chipper.locale, 'phet.chipper.locale global expected' );
assert && assert( phet.chipper.localeData, 'phet.chipper.localeData global expected' );
assert && assert( phet.chipper.strings, 'phet.chipper.strings global expected' );
assert && assert( phet.chipper.queryParameters.locale, 'should exist with a default' );

/**
* Given a locale based on the supported query parameter schema, map it to the 2 or 5 char locale code (key in localeData).
*/
const remapLocale = ( locale: string ) => {
assert && assert( locale );
assert && assert( phet.chipper.localeData );

const inputValueLocale = locale;

if ( locale.length < 5 ) {
locale = locale.toLowerCase();
}
else {
locale = locale.replace( /-/, '_' );

const parts = locale.split( '_' );
if ( parts.length === 2 ) {
locale = parts[ 0 ].toLowerCase() + '_' + parts[ 1 ].toUpperCase();
}
}

if ( locale.length === 3 ) {
for ( const candidateLocale of Object.keys( phet.chipper.localeData ) ) {
if ( phet.chipper.localeData[ candidateLocale ].locale3 === locale ) {
locale = candidateLocale;
break;
}
}
}

// Permissive patterns for locale query parameter patterns.
// We don't want to show a query parameter warning if it matches these patterns, EVEN if it is not a valid locale
// in localeData, see https://github.com/phetsims/qa/issues/1085#issuecomment-2111105235.
const pairRegex = /^[a-zA-Z]{2}$/;
const tripleRegex = /^[a-zA-Z]{3}$/;
const doublePairRegex = /^[a-zA-Z]{2}[_-][a-zA-Z]{2}$/;

// Sanity checks for verifying localeData (so hopefully we don't commit bad data to localeData).
if ( assert ) {
for ( const locale of Object.keys( phet.chipper.localeData ) ) {
// Check the locale itself
assert( pairRegex.test( locale ) || doublePairRegex.test( locale ), `Invalid locale format: ${locale}` );

// Check locale3 (if it exists)
if ( phet.chipper.localeData[ locale ].locale3 ) {
assert( tripleRegex.test( phet.chipper.localeData[ locale ].locale3 ), `Invalid locale3 format: ${phet.chipper.localeData[ locale ].locale3}` );
}

// Check fallbackLocales (if it exists)
if ( phet.chipper.localeData[ locale ].fallbackLocales ) {
for ( const fallbackLocale of phet.chipper.localeData[ locale ].fallbackLocales ) {
assert( phet.chipper.localeData[ fallbackLocale ] );
}
}
}
}

if ( !phet.chipper.localeData[ locale ] ) {
const badLocale = inputValueLocale;

if ( !pairRegex.test( badLocale ) && !tripleRegex.test( badLocale ) && !doublePairRegex.test( badLocale ) ) {
assert && assert( false, 'invalid locale:', inputValueLocale );
}

locale = FALLBACK_LOCALE;
}

return locale;
};

/**
* Get the "most" valid locale, see https://github.com/phetsims/phet-io/issues/1882
* As part of https://github.com/phetsims/joist/issues/963, this as changed. We check a specific fallback order based
* on the locale. In general, it will usually try a prefix for xx_XX style locales, e.g. 'ar_SA' would try 'ar_SA', 'ar', 'en'
* NOTE: If the locale doesn't actually have any strings: THAT IS OK! Our string system will use the appropriate
* fallback strings.
*/
const getValidRuntimeLocale = ( locale: string ) => {
assert && assert( locale );
assert && assert( phet.chipper.localeData );
assert && assert( phet.chipper.strings );

const possibleLocales = [
locale,
...( phet.chipper.localeData[ locale ]?.fallbackLocales ?? [] ),
FALLBACK_LOCALE
];

const availableLocale = possibleLocales.find( possibleLocale => !!phet.chipper.strings[ possibleLocale ] );
assert && assert( availableLocale, 'no fallback found for ', locale );
return availableLocale;
};

// We will need to check for locale validity (once we have localeData loaded, if running unbuilt), and potentially
// either fall back to `en`, or remap from 3-character locales to our locale keys. This overwrites phet.chipper.locale.
// Used when setting locale through JOIST/localeProperty also. Default to the query parameter instead of
// chipper.locale because we overwrite that value, and may run this function multiple times during the startup
// sequence (in unbuilt mode).
const checkAndRemapLocale = ( locale: string ) => {

// We need both to proceed. Provided as a global, so we can call it from load-unbuilt-strings
// (IF initialize-globals loads first). Also handle the unbuilt mode case where we have phet.chipper.strings
// exists but no translations have loaded yet.
if ( !phet.chipper.localeData || !phet.chipper.strings?.hasOwnProperty( FALLBACK_LOCALE ) || !locale ) {
return locale;
}

const remappedLocale = remapLocale( locale );
const finalLocale = getValidRuntimeLocale( remappedLocale );

phet.chipper.locale = finalLocale; // NOTE: this will change with every setting of JOIST/localeProperty
return finalLocale;
};

// All available locales for the runtime
export const availableRuntimeLocales = _.sortBy( Object.keys( phet.chipper.strings ), locale => {
return StringUtils.localeToLocalizedName( locale ).toLowerCase();
} );

// Start only with a valid locale, see https://github.com/phetsims/phet-io/issues/1882
const isLocaleValid = ( locale?: Locale ): boolean => {
return !!( locale && availableRuntimeLocales.includes( locale ) );
};
export class LocaleProperty extends Property<Locale> {
public readonly availableRuntimeLocales: Locale[] = availableRuntimeLocales;

// Get the "most" valid locale, see https://github.com/phetsims/phet-io/issues/1882
// As part of https://github.com/phetsims/joist/issues/963, this as changed. We check a specific fallback order based
// on the locale. In general, it will usually try a prefix for xx_XX style locales, e.g. 'ar_SA' would try 'ar_SA', 'ar', 'en'
// NOTE: If the locale doesn't actually have any strings: THAT IS OK! Our string system will use the appropriate
// fallback strings.
const validInitialLocale = [
phet.chipper.locale,
...( phet.chipper.localeData[ phet.chipper.locale ]?.fallbackLocales ?? [] ),
FALLBACK_LOCALE
].find( isLocaleValid );

// Just in case we had an invalid locale, remap phet.chipper.locale to the "corrected" value
phet.chipper.locale = validInitialLocale;

class LocaleProperty extends Property<Locale> {
protected override unguardedSet( value: Locale ): void {
if ( availableRuntimeLocales.includes( value ) ) {
super.unguardedSet( value );
}
else {
assert && assert( false, 'Unsupported locale: ' + value );
// NOTE: updates phet.chipper.locale as a side-effect
super.unguardedSet( checkAndRemapLocale( value ) );
}

// Do not try to set if the value was invalid
}
// This improves the PhET-iO Studio interface, by giving available values, without triggering validation if you want
// to use the more general locale schema (three digit/case-insensitive/etc).
protected override toStateObject<StateType>(): ReadOnlyPropertyState<StateType> {
const parentObject = super.toStateObject<StateType>();

// Provide via validValues without forcing validation assertions if a different value is set.
parentObject.validValues = this.availableRuntimeLocales as StateType[];
return parentObject;
}

protected override applyState<StateType>( stateObject: ReadOnlyPropertyState<StateType> ): void {
stateObject.validValues = null; // TODO: this should be removed in https://github.com/phetsims/axon/issues/453
super.applyState( stateObject );
}
}

const localeProperty = new LocaleProperty( validInitialLocale, {
const localeProperty = new LocaleProperty( phet.chipper.locale, {
tandem: Tandem.GENERAL_MODEL.createTandem( 'localeProperty' ),
phetioFeatured: true,
phetioValueType: StringIO,
validValues: availableRuntimeLocales
phetioValueType: StringIO
} );

if ( phet.chipper.queryParameters.keyboardLocaleSwitcher ) {
Expand Down
7 changes: 3 additions & 4 deletions js/preferences/LocalePanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,15 @@
import joist from '../joist.js';
import Panel from '../../../sun/js/Panel.js';
import { GridBox } from '../../../scenery/js/imports.js';
import Property from '../../../axon/js/Property.js';
import LanguageSelectionNode from './LanguageSelectionNode.js';
import { Locale } from '../i18n/localeProperty.js';
import { LocaleProperty } from '../i18n/localeProperty.js';
import StringUtils from '../../../phetcommon/js/util/StringUtils.js';

class LocalePanel extends Panel {
private readonly disposeLocalePanel: () => void;

public constructor( localeProperty: Property<Locale> ) {
const locales = localeProperty.validValues!;
public constructor( localeProperty: LocaleProperty ) {
const locales = localeProperty.availableRuntimeLocales;

// Sort these properly by their localized name (without using _.sortBy, since string comparison does not provide
// a good sorting experience). See https://github.com/phetsims/joist/issues/965
Expand Down
8 changes: 4 additions & 4 deletions js/preferences/PreferencesModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import optionize, { EmptySelfOptions } from '../../../phet-core/js/optionize.js'
import regionAndCultureManager from './regionAndCultureManager.js';
import SpeechSynthesisAnnouncer from '../../../utterance-queue/js/SpeechSynthesisAnnouncer.js';
import Tandem from '../../../tandem/js/Tandem.js';
import localeProperty, { Locale } from '../i18n/localeProperty.js';
import localeProperty, { LocaleProperty } from '../i18n/localeProperty.js';
import merge from '../../../phet-core/js/merge.js';
import TReadOnlyProperty from '../../../axon/js/TReadOnlyProperty.js';
import IOType from '../../../tandem/js/types/IOType.js';
Expand Down Expand Up @@ -178,7 +178,7 @@ export type LocalizationModel = BaseModelType & {
// The selected character artwork to use when the sim supports culture and region switching.
regionAndCultureProperty: Property<CharacterSet | null>;

localeProperty: Property<Locale>;
localeProperty: LocaleProperty;
} & Required<LocalizationPreferencesOptions>;

type FeatureModel = SimulationModel | AudioModel | VisualModel | InputModel | LocalizationModel;
Expand Down Expand Up @@ -235,7 +235,7 @@ export default class PreferencesModel extends PhetioObject {
}, providedOptions.inputOptions ),
localizationOptions: optionize<LocalizationPreferencesOptions, LocalizationPreferencesOptions, BaseModelType>()( {
tandemName: 'localizationModel',
supportsDynamicLocales: !!localeProperty.validValues && localeProperty.validValues.length > 1 && phet.chipper.allowLocaleSwitching,
supportsDynamicLocales: !!localeProperty.availableRuntimeLocales && localeProperty.availableRuntimeLocales.length > 1 && phet.chipper.allowLocaleSwitching,
characterSets: [],
customPreferences: []
}, providedOptions.localizationOptions )
Expand All @@ -259,7 +259,7 @@ export default class PreferencesModel extends PhetioObject {
// is supported if English is available, but not enabled until English becomes the running locale.
const supportsVoicing = options.audioOptions.supportsVoicing &&
SpeechSynthesisAnnouncer.isSpeechSynthesisSupported() &&
_.some( localeProperty.validValues, value => value.startsWith( 'en' ) );
_.some( localeProperty.availableRuntimeLocales, value => value.startsWith( 'en' ) );

// Audio can be disabled explicitly via query parameter
const audioEnabled = phet.chipper.queryParameters.audio !== 'disabled';
Expand Down
2 changes: 1 addition & 1 deletion js/preferences/VoicingPanelSection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ class VoicingPanelSection extends PreferencesPanelSection {

// Voicing feature only works when running in English. If running in a version where you can change locale,
// indicate through the title that the feature will only work in English.
const titleStringProperty = ( localeProperty.validValues && localeProperty.validValues.length > 1 ) ?
const titleStringProperty = ( localeProperty.availableRuntimeLocales && localeProperty.availableRuntimeLocales.length > 1 ) ?
voicingEnglishOnlyLabelStringProperty : voicingLabelStringProperty;

// the checkbox is the title for the section and totally enables/disables the feature
Expand Down

0 comments on commit 3303641

Please sign in to comment.