Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, slots: Record<string, ExternalComponent[]> = {}): 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 #');
});
});
1 change: 1 addition & 0 deletions Source/JavaScript/engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ export * from './incompatibleStarterThemes';
export * from './ActionDispatcher';
export * from './runActions';
export * from './resolveBehaviors';
export * from './resolveStrings';
84 changes: 84 additions & 0 deletions Source/JavaScript/engine/resolveStrings.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

const referencePrefix = '$strings.';

/**
* Whether a property value is a `$strings.<key>` reference rather than a literal value.
*
* A document prints `$strings.<key>` 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.<key>` 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;
}
Loading