From df2757d1e7125e96a9b62de049d500341c4cfa84 Mon Sep 17 00:00:00 2001 From: Jesse Date: Fri, 22 Nov 2024 15:29:32 -0500 Subject: [PATCH] move utility files related to Fluent.js to chipper so they can be used in multiple sim prototypes, see https://github.com/phetsims/joist/issues/992 and https://github.com/phetsims/chipper/issues/1532 --- js/FluentUtils.ts | 63 +++++++++++++++++++++++++++++ js/LocalizedMessageProperty.ts | 38 +++++++++++++++++ js/PatternMessageProperty.ts | 41 +++++++++++++++++++ js/localizedFluentBundleProperty.ts | 50 +++++++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 js/FluentUtils.ts create mode 100644 js/LocalizedMessageProperty.ts create mode 100644 js/PatternMessageProperty.ts create mode 100644 js/localizedFluentBundleProperty.ts diff --git a/js/FluentUtils.ts b/js/FluentUtils.ts new file mode 100644 index 00000000..03938894 --- /dev/null +++ b/js/FluentUtils.ts @@ -0,0 +1,63 @@ +// Copyright 2024, University of Colorado Boulder + +/** + * Utility functions for working with Fluent strings. + * + * @author Jesse Greenberg (PhET Interactive Simulations) + */ + +import { isTReadOnlyProperty } from '../../axon/js/TReadOnlyProperty.js'; +import IntentionalAny from '../../phet-core/js/types/IntentionalAny.js'; +import localizedFluentBundleProperty from './localizedFluentBundleProperty.js'; +import LocalizedMessageProperty from './LocalizedMessageProperty.js'; + +const FluentUtils = { + + /** + * Converts a camelCase id to a message key. For example, 'choose-unit-for-current' becomes + * 'chooseUnitForCurrentMessageProperty'. + */ + fluentIdToMessageKey: ( id: string ): string => { + return `${id.replace( /-([a-z])/g, ( match, letter ) => letter.toUpperCase() )}MessageProperty`; + }, + + /** + * Changes a set of arguments for the message into a set of values that can easily be used to + * format the message. Does things like get Property values and converts enumeration values to strings. + */ + handleFluentArgs: ( args: IntentionalAny ): IntentionalAny => { + const keys = Object.keys( args ); + + const newArgs: Record = {}; + + keys.forEach( key => { + let value = args[ key ]; + + // If the value is a Property, get the value. + if ( isTReadOnlyProperty( value ) ) { + value = value.value; + } + + // If the value is an EnumerationValue, automatically use the enum name. + if ( value && value.name ) { + value = value.name; + } + + newArgs[ key ] = value; + } ); + + return newArgs; + }, + + /**h + * Directly format a fluent message. Most of the time, you should use a PatternMessageProperty instead. + * This should only be used when the string does not need to be changed when the locale changes. Real-time + * alerts are a good exaple. + */ + formatMessage: ( localizedMessageProperty: LocalizedMessageProperty, args: IntentionalAny ): string => { + const newArgs = FluentUtils.handleFluentArgs( args ); + return localizedFluentBundleProperty.value.format( localizedMessageProperty.value, newArgs ); + } +}; + +export default FluentUtils; \ No newline at end of file diff --git a/js/LocalizedMessageProperty.ts b/js/LocalizedMessageProperty.ts new file mode 100644 index 00000000..02ff7990 --- /dev/null +++ b/js/LocalizedMessageProperty.ts @@ -0,0 +1,38 @@ +// Copyright 2024, University of Colorado Boulder + +/** + * Prototype: A Property whose value is a message from a Fluent bundle. A Fluent bundle is a collection of messages + * for a single locale. When the locale changes, the bundle will change, and then the LocalizedMessageProperty will + * compute a new value based on the language and arguments. + * + * See https://github.com/phetsims/joist/issues/992. + * + * This is for a proof of concept. By creating Properties, we get a good sense of what usages would be like in simulation + * code. But it is a partial implementation. The full solution needs to consider PhET-iO control, and be more integrated + * into PhET's string modules. + * + * @author Jesse Greenberg (PhET Interactive Simulations) + */ + +import { DerivedProperty1 } from '../../axon/js/DerivedProperty.js'; +import chipper from './chipper.js'; +import localizedFluentBundleProperty, { englishBundle } from './localizedFluentBundleProperty.js'; + +export default class LocalizedMessageProperty extends DerivedProperty1 { + + /** + * @param id - the id of the message in the fluent bundle + */ + public constructor( id: string ) { + + // Just to get Property interface working, but this needs to be bi-directional + // and use LocalizedString/LocalizedStringProperty stack. + super( [ localizedFluentBundleProperty ], () => { + + // If the bundle does not have the message, fall back to english. + return localizedFluentBundleProperty.value.getMessage( id ) || englishBundle.getMessage( id ); + } ); + } +} + +chipper.register( 'LocalizedMessageProperty', LocalizedMessageProperty ); \ No newline at end of file diff --git a/js/PatternMessageProperty.ts b/js/PatternMessageProperty.ts new file mode 100644 index 00000000..6e3bf98f --- /dev/null +++ b/js/PatternMessageProperty.ts @@ -0,0 +1,41 @@ +// Copyright 2024, University of Colorado Boulder + +/** + * A Property whose value is a message from a bundle with arguments. Each argument can be a Property, + * and the message will be updated either the message or the argument changes. + * + * A similar idea as PatternStringProperty, but for Fluent messages. + * + * @author Jesse Greenberg (PhET Interactive Simulations) + */ + +import { DerivedProperty1 } from '../../axon/js/DerivedProperty.js'; +import TReadOnlyProperty, { isTReadOnlyProperty } from '../../axon/js/TReadOnlyProperty.js'; +import IntentionalAny from '../../phet-core/js/types/IntentionalAny.js'; +import chipper from './chipper.js'; +import FluentUtils from './FluentUtils.js'; +import localizedFluentBundleProperty from './localizedFluentBundleProperty.js'; +import LocalizedMessageProperty from './LocalizedMessageProperty.js'; + +export default class PatternMessageProperty extends DerivedProperty1 { + public constructor( messageProperty: LocalizedMessageProperty, values: IntentionalAny ) { + const dependencies: TReadOnlyProperty[] = [ messageProperty ]; + const keys = Object.keys( values ); + keys.forEach( key => { + if ( isTReadOnlyProperty( values[ key ] ) ) { + dependencies.push( values[ key ] ); + } + } ); + + // @ts-expect-error This is a prototype so I am not going to worry about this complicated TS for now. + super( dependencies, ( message, ...unusedArgs ) => { + + const args = FluentUtils.handleFluentArgs( values ); + + // Format the message with the arguments to resolve a string. + return localizedFluentBundleProperty.value.format( message, args ); + } ); + } +} + +chipper.register( 'PatternMessageProperty', PatternMessageProperty ); \ No newline at end of file diff --git a/js/localizedFluentBundleProperty.ts b/js/localizedFluentBundleProperty.ts new file mode 100644 index 00000000..f79980e8 --- /dev/null +++ b/js/localizedFluentBundleProperty.ts @@ -0,0 +1,50 @@ +// Copyright 2024, University of Colorado Boulder + +/** + * Prototype for a Property that provides a FluentBundle for the current locale. + * Used by LocalizedMessageProperty to get the correct Fluent message for the current locale. + * + * Fluent has the following concepts: + * - Bundle: A collection of messages for a single locale. + * - Message: An data structure in a FluentBundle. The message can be formatted with arguments into a final string. + * If there are no arguments, the message is a string. + * + * @author Jesse Greenberg (PhET Interactive Simulations) + */ + +import DerivedProperty from '../../axon/js/DerivedProperty.js'; +import localeProperty from '../../joist/js/i18n/localeProperty.js'; + +const bundleMap = new Map(); + +// @ts-expect-error - Why isn't Fluent available here? It was availabel in ohms-law. I expected +// it to be available because the types are included in perennial's package.json. +const FluentRef = Fluent; + +// Create the Fluent bundles for each locale, and save them to the map for use. +localeProperty.availableRuntimeLocales.forEach( locale => { + + // If strings are available for the locale, create a bundle. Graceful fallbacks + // happen in the Properties below. + if ( phet.chipper.fluentStrings[ locale ] ) { + const bundle = new FluentRef.FluentBundle( locale ); + const resource = FluentRef.FluentResource.fromString( phet.chipper.fluentStrings[ locale ] ); + bundle.addResource( resource ); + + bundleMap.set( locale, bundle ); + } +} ); + +// Get the english fallback bundle. +const englishBundle = bundleMap.get( 'en' ); +if ( !englishBundle ) { + throw new Error( 'English bundle is required' ); +} + +// The bundle for the selected locale. Fall back to english if the bundle isn't available. +const localizedBundleProperty = new DerivedProperty( [ localeProperty ], locale => { + return bundleMap.has( locale ) ? bundleMap.get( locale ) : englishBundle; +} ); + +export default localizedBundleProperty; +export { englishBundle }; \ No newline at end of file