diff --git a/js/i18n/localeProperty.ts b/js/i18n/localeProperty.ts index ffd6a01d..0207ff87 100644 --- a/js/i18n/localeProperty.ts +++ b/js/i18n/localeProperty.ts @@ -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 { + 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 { 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(): ReadOnlyPropertyState { + const parentObject = super.toStateObject(); + + // Provide via validValues without forcing validation assertions if a different value is set. + parentObject.validValues = this.availableRuntimeLocales as StateType[]; + return parentObject; + } + + protected override applyState( stateObject: ReadOnlyPropertyState ): 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 ) { diff --git a/js/preferences/LocalePanel.ts b/js/preferences/LocalePanel.ts index 1045c8f2..0b009652 100644 --- a/js/preferences/LocalePanel.ts +++ b/js/preferences/LocalePanel.ts @@ -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 ) { - 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 diff --git a/js/preferences/PreferencesModel.ts b/js/preferences/PreferencesModel.ts index 05f86f7b..6e350657 100644 --- a/js/preferences/PreferencesModel.ts +++ b/js/preferences/PreferencesModel.ts @@ -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'; @@ -178,7 +178,7 @@ export type LocalizationModel = BaseModelType & { // The selected character artwork to use when the sim supports culture and region switching. regionAndCultureProperty: Property; - localeProperty: Property; + localeProperty: LocaleProperty; } & Required; type FeatureModel = SimulationModel | AudioModel | VisualModel | InputModel | LocalizationModel; @@ -235,7 +235,7 @@ export default class PreferencesModel extends PhetioObject { }, providedOptions.inputOptions ), localizationOptions: optionize()( { 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 ) @@ -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'; diff --git a/js/preferences/VoicingPanelSection.ts b/js/preferences/VoicingPanelSection.ts index 930e51e3..86a0bccb 100644 --- a/js/preferences/VoicingPanelSection.ts +++ b/js/preferences/VoicingPanelSection.ts @@ -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