From 67d8842999a62f870787fe7df1e2eda8aad2e788 Mon Sep 17 00:00:00 2001 From: Einar Date: Thu, 24 Sep 2026 07:25:12 +0200 Subject: [PATCH 1/2] Give a $strings. reference somewhere to resolve against, at runtime Screenplay compiles a $strings. reference symbolically and prints it unquoted - nothing in Scene or Stage ever turned it into the text a .strings file actually holds. Stage's frontend rendered the literal token, because resolution has to happen at runtime for a locale switch to take effect without recompiling the model, and nothing in either repository did that work. resolveStringsInElement walks an element tree the same way the rest of this package already distinguishes element kinds - an ExternalComponent's named slots, a ContentControl's content, an ItemsControl's itemTemplate, a Panel's children - resolving every $strings. reference in every properties bag it finds along the way against a flat locale dictionary. A missing key is left as its literal token rather than blanked out, the same failure mode a screen already had before resolution existed, and one an author notices immediately instead of a silently empty label. This is deliberately the generic half only: turning a dictionary and a tree into a resolved tree, with no opinion about where the dictionary comes from. Discovering and parsing the .strings files themselves is filesystem access only a host has - Stage now owns that side. --- .../when_resolving_a_strings_reference.ts | 77 +++++++++++++++++ Source/JavaScript/engine/index.ts | 1 + Source/JavaScript/engine/resolveStrings.ts | 84 +++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 Source/JavaScript/engine/for_resolveStrings/when_resolving_a_strings_reference.ts create mode 100644 Source/JavaScript/engine/resolveStrings.ts 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..b1620a2d --- /dev/null +++ b/Source/JavaScript/engine/for_resolveStrings/when_resolving_a_strings_reference.ts @@ -0,0 +1,77 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ExternalComponent } from '@cratis/scene.model'; +import { isStringsReference, resolveStringsInElement, resolveStringsValue } from '../index'; + +const dictionary = { + 'invoice.title': 'Invoices', + 'invoice.number': 'Invoice #', +}; + +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).should.equal('Invoices'); + }); + + it('should leave a reference missing from the dictionary as its literal token', () => { + resolveStringsValue('$strings.invoice.missing', dictionary).should.equal('$strings.invoice.missing'); + }); + + it('should pass a non-reference value through unchanged', () => { + resolveStringsValue(42, dictionary).should.equal(42); + }); +}); + +describe('when resolving references nested inside an external component tree', () => { + const title: ExternalComponent = { + id: 'title', + componentName: 'core:title', + properties: { text: '$strings.invoice.title' }, + slots: {}, + }; + const table: ExternalComponent = { + id: 'table', + componentName: 'core:table', + properties: {}, + slots: { + columns: [ + { + id: 'column', + componentName: 'core:column', + properties: { label: '$strings.invoice.number' }, + slots: {}, + } as ExternalComponent, + ], + }, + }; + + 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!.should.equal('Invoices'); + }); + + it('should leave the original element untouched', () => { + title.properties.text!.should.equal('$strings.invoice.title'); + }); + + it('should resolve a reference nested inside a slot', () => { + resolvedTable.slots.columns[0].properties.label!.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; +} From fc5de4698bf56d3bd7197a88a68dcf2bcd5426b6 Mon Sep 17 00:00:00 2001 From: Einar Date: Thu, 24 Sep 2026 07:29:39 +0200 Subject: [PATCH 2/2] Cast resolveStringsValue result before asserting against it in specs --- .../when_resolving_a_strings_reference.ts | 64 +++++++++++-------- 1 file changed, 36 insertions(+), 28 deletions(-) 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 index b1620a2d..54dda333 100644 --- a/Source/JavaScript/engine/for_resolveStrings/when_resolving_a_strings_reference.ts +++ b/Source/JavaScript/engine/for_resolveStrings/when_resolving_a_strings_reference.ts @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { ExternalComponent } from '@cratis/scene.model'; +import { Control, ExternalComponent, HorizontalAlignment, VerticalAlignment, Visibility } from '@cratis/scene.model'; import { isStringsReference, resolveStringsInElement, resolveStringsValue } from '../index'; const dictionary = { @@ -9,6 +9,31 @@ const dictionary = { '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; @@ -25,53 +50,36 @@ describe('when telling a strings reference apart from a literal value', () => { describe('when resolving a single property value', () => { it('should resolve a reference present in the dictionary', () => { - resolveStringsValue('$strings.invoice.title', dictionary).should.equal('Invoices'); + (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).should.equal('$strings.invoice.missing'); + (resolveStringsValue('$strings.invoice.missing', dictionary) as string).should.equal('$strings.invoice.missing'); }); it('should pass a non-reference value through unchanged', () => { - resolveStringsValue(42, dictionary).should.equal(42); + (resolveStringsValue(42, dictionary) as number).should.equal(42); }); }); describe('when resolving references nested inside an external component tree', () => { - const title: ExternalComponent = { - id: 'title', - componentName: 'core:title', - properties: { text: '$strings.invoice.title' }, - slots: {}, - }; - const table: ExternalComponent = { - id: 'table', - componentName: 'core:table', - properties: {}, - slots: { - columns: [ - { - id: 'column', - componentName: 'core:column', - properties: { label: '$strings.invoice.number' }, - slots: {}, - } as ExternalComponent, - ], - }, - }; + 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!.should.equal('Invoices'); + (resolvedTitle.properties.text as string).should.equal('Invoices'); }); it('should leave the original element untouched', () => { - title.properties.text!.should.equal('$strings.invoice.title'); + (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!.should.equal('Invoice #'); + (resolvedTable.slots.columns[0].properties.label as string).should.equal('Invoice #'); }); });