diff --git a/Source/JavaScript/engine/for_resolveStrings/when_resolving_a_strings_reference.ts b/Source/JavaScript/engine/for_resolveStrings/when_resolving_a_strings_reference.ts new file mode 100644 index 00000000..54dda333 --- /dev/null +++ b/Source/JavaScript/engine/for_resolveStrings/when_resolving_a_strings_reference.ts @@ -0,0 +1,85 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Control, ExternalComponent, HorizontalAlignment, VerticalAlignment, Visibility } from '@cratis/scene.model'; +import { isStringsReference, resolveStringsInElement, resolveStringsValue } from '../index'; + +const dictionary = { + 'invoice.title': 'Invoices', + 'invoice.number': 'Invoice #', +}; + +function control(id: string): Control { + return { + id, + name: id, + properties: {}, + visibility: Visibility.Visible, + isEnabled: true, + opacity: 1, + size: {}, + zIndex: 0, + minimumSize: {}, + maximumSize: {}, + margin: { left: 0, top: 0, right: 0, bottom: 0 }, + horizontalAlignment: HorizontalAlignment.Stretch, + verticalAlignment: VerticalAlignment.Stretch, + borderThickness: { left: 0, top: 0, right: 0, bottom: 0 }, + padding: { left: 0, top: 0, right: 0, bottom: 0 }, + tabIndex: 0, + }; +} + +function externalComponent(id: string, componentName: string, properties: Record, slots: Record = {}): ExternalComponent { + return { ...control(id), componentName, properties, slots }; +} + +describe('when telling a strings reference apart from a literal value', () => { + it('should recognize a $strings. reference', () => { + isStringsReference('$strings.invoice.title').should.be.true; + }); + + it('should reject a literal value', () => { + isStringsReference('Invoices').should.be.false; + }); + + it('should reject a non-string value', () => { + isStringsReference(42).should.be.false; + }); +}); + +describe('when resolving a single property value', () => { + it('should resolve a reference present in the dictionary', () => { + (resolveStringsValue('$strings.invoice.title', dictionary) as string).should.equal('Invoices'); + }); + + it('should leave a reference missing from the dictionary as its literal token', () => { + (resolveStringsValue('$strings.invoice.missing', dictionary) as string).should.equal('$strings.invoice.missing'); + }); + + it('should pass a non-reference value through unchanged', () => { + (resolveStringsValue(42, dictionary) as number).should.equal(42); + }); +}); + +describe('when resolving references nested inside an external component tree', () => { + const title = externalComponent('title', 'core:title', { text: '$strings.invoice.title' }); + const table = externalComponent('table', 'core:table', {}, { + columns: [externalComponent('column', 'core:column', { label: '$strings.invoice.number' })], + }); + + const resolvedTitle = resolveStringsInElement(title, dictionary) as ExternalComponent; + const resolvedTable = resolveStringsInElement(table, dictionary) as ExternalComponent; + + it('should resolve a reference at the top of the tree', () => { + (resolvedTitle.properties.text as string).should.equal('Invoices'); + }); + + it('should leave the original element untouched', () => { + (title.properties.text as string).should.equal('$strings.invoice.title'); + }); + + it('should resolve a reference nested inside a slot', () => { + (resolvedTable.slots.columns[0].properties.label as string).should.equal('Invoice #'); + }); +}); diff --git a/Source/JavaScript/engine/index.ts b/Source/JavaScript/engine/index.ts index 6cbe6f01..99f6ebc4 100644 --- a/Source/JavaScript/engine/index.ts +++ b/Source/JavaScript/engine/index.ts @@ -25,3 +25,4 @@ export * from './incompatibleStarterThemes'; export * from './ActionDispatcher'; export * from './runActions'; export * from './resolveBehaviors'; +export * from './resolveStrings'; diff --git a/Source/JavaScript/engine/resolveStrings.ts b/Source/JavaScript/engine/resolveStrings.ts new file mode 100644 index 00000000..2df3ebcd --- /dev/null +++ b/Source/JavaScript/engine/resolveStrings.ts @@ -0,0 +1,84 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { SceneElement } from '@cratis/scene.model'; +import { isContentControl, isExternalComponent, isItemsControl, isPanel } from './elementKind'; + +/** + * A locale's flat key -> value table, as `.strings` files parse to: dotted keys such as `invoice.title` + * mapped to their localized text. + */ +export type StringsDictionary = Record; + +const referencePrefix = '$strings.'; + +/** + * Whether a property value is a `$strings.` reference rather than a literal value. + * + * A document prints `$strings.` unquoted - see Screenplay's internationalization documentation - so + * the reference survives into a compiled element's properties as this exact string shape, indistinguishable + * from any other string property until something resolves it. + */ +export function isStringsReference(value: unknown): value is string { + return typeof value === 'string' && value.startsWith(referencePrefix); +} + +/** + * Resolves a single property value against a locale's dictionary. + * + * A value that is not a `$strings.` reference passes through unchanged - resolving is a no-op on anything + * else a screen might put in the same property. A reference whose key is missing from the dictionary is + * left as the literal `$strings.` text rather than blanked out - the same failure mode the frontend + * already had before resolution existed, and one an author notices immediately rather than a silently + * empty label. + */ +export function resolveStringsValue(value: unknown, dictionary: StringsDictionary): unknown { + if (!isStringsReference(value)) { + return value; + } + + const key = value.slice(referencePrefix.length); + return Object.hasOwn(dictionary, key) ? dictionary[key] : value; +} + +/** + * Resolves every `$strings.` reference in an element tree's `properties` against a locale's dictionary, + * returning a new tree - the input is never mutated, so a caller holding the original (a cached scene, + * a Studio design-time fixture) keeps seeing the unresolved symbolic form. + * + * Walks every shape an element tree actually nests content in: an `ExternalComponent`'s named `slots`, a + * `ContentControl`'s single `content`, an `ItemsControl`'s `itemTemplate`, and a `Panel`'s `children`. This + * is the same set {@link isExternalComponent}/{@link isContentControl}/{@link isItemsControl}/{@link isPanel} + * already distinguish elsewhere in this package, so a new element kind that starts nesting content a new + * way needs a matching type guard there before it can be walked here too. + */ +export function resolveStringsInElement(element: SceneElement, dictionary: StringsDictionary): SceneElement { + const properties = Object.fromEntries( + Object.entries(element.properties ?? {}).map(([key, value]) => [key, resolveStringsValue(value, dictionary)]), + ); + + const resolved: SceneElement = { ...element, properties }; + + if (isExternalComponent(resolved)) { + resolved.slots = Object.fromEntries( + Object.entries(resolved.slots ?? {}).map(([slotName, children]) => [ + slotName, + children.map(child => resolveStringsInElement(child, dictionary)), + ]), + ); + } + + if (isContentControl(resolved) && resolved.content) { + resolved.content = resolveStringsInElement(resolved.content, dictionary); + } + + if (isItemsControl(resolved) && resolved.itemTemplate) { + resolved.itemTemplate = resolveStringsInElement(resolved.itemTemplate, dictionary); + } + + if (isPanel(resolved)) { + resolved.children = resolved.children.map(child => resolveStringsInElement(child, dictionary)); + } + + return resolved; +}