refactor(ivy): i18n - run-time translation API to use message-id (#32594)

Previously the translation key used for translations was the `SourceMessage`
but it turns out that this is insufficient because "meaning" and "custom-id"
metadata affect the translation key.

Now run-time translation is keyed off the `MessageId`.

PR Close #32594
This commit is contained in:
Pete Bacon Darwin 2019-09-13 12:46:05 +01:00 committed by Andrew Kushnir
parent c7abb7d196
commit 870d189433
6 changed files with 46 additions and 43 deletions

View File

@ -6,7 +6,8 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {LocalizeFn} from './localize'; import {LocalizeFn} from './localize';
import {ParsedTranslation, TargetMessage, TranslationKey, parseTranslation, translate as _translate} from './utils/translations'; import {MessageId, TargetMessage} from './utils/messages';
import {ParsedTranslation, parseTranslation, translate as _translate} from './utils/translations';
/** /**
* We augment the `$localize` object to also store the translations. * We augment the `$localize` object to also store the translations.
@ -24,7 +25,7 @@ declare const $localize: LocalizeFn&{TRANSLATIONS: Record<string, ParsedTranslat
* *
* @publicApi * @publicApi
*/ */
export function loadTranslations(translations: Record<TranslationKey, TargetMessage>) { export function loadTranslations(translations: Record<MessageId, TargetMessage>) {
// Ensure the translate function exists // Ensure the translate function exists
if (!$localize.translate) { if (!$localize.translate) {
$localize.translate = translate; $localize.translate = translate;

View File

@ -7,13 +7,15 @@
*/ */
/** /**
* The character used to mark the start and end of a placeholder name in a `$localize` tagged * The character used to mark the start and end of a "block" in a `$localize` tagged string.
* string. * A block can indicate metadata about the message or specify a name of a placeholder for a
* substitution expressions.
* *
* For example: * For example:
* *
* ``` * ```ts
* $localize`Hello, ${title}:title:!`; * $localize`Hello, ${title}:title:!`;
* $localize`:meaning|description@@id:source message text`;
* ``` * ```
*/ */
export const PLACEHOLDER_NAME_MARKER = ':'; export const BLOCK_MARKER = ':';

View File

@ -5,8 +5,7 @@
* Use of this source code is governed by an MIT-style license that can be * Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {PLACEHOLDER_NAME_MARKER} from './constants'; import {BLOCK_MARKER} from './constants';
import {TranslationKey} from './translations';
/** /**
* A string containing a translation source message. * A string containing a translation source message.
@ -17,6 +16,20 @@ import {TranslationKey} from './translations';
*/ */
export type SourceMessage = string; export type SourceMessage = string;
/**
* A string containing a translation target message.
*
* I.E. the message that indicates what will be translated to.
*
* Uses `{$placeholder-name}` to indicate a placeholder.
*/
export type TargetMessage = string;
/**
* A string that uniquely identifies a message, to be used for matching translations.
*/
export type MessageId = string;
/** /**
* Information parsed from a `$localize` tagged string that is used to translate it. * Information parsed from a `$localize` tagged string that is used to translate it.
* *
@ -31,7 +44,7 @@ export type SourceMessage = string;
* *
* ``` * ```
* { * {
* translationKey: 'Hello {$title}!', * messageId: '6998194507597730591',
* substitutions: { title: 'Jo Bloggs' }, * substitutions: { title: 'Jo Bloggs' },
* } * }
* ``` * ```
@ -40,7 +53,7 @@ export interface ParsedMessage {
/** /**
* The key used to look up the appropriate translation target. * The key used to look up the appropriate translation target.
*/ */
translationKey: TranslationKey; messageId: MessageId;
/** /**
* A mapping of placeholder names to substitution values. * A mapping of placeholder names to substitution values.
*/ */
@ -55,7 +68,7 @@ export interface ParsedMessage {
export function parseMessage( export function parseMessage(
messageParts: TemplateStringsArray, expressions: readonly any[]): ParsedMessage { messageParts: TemplateStringsArray, expressions: readonly any[]): ParsedMessage {
const replacements: {[placeholderName: string]: any} = {}; const replacements: {[placeholderName: string]: any} = {};
let translationKey = messageParts[0]; let messageId = messageParts[0];
for (let i = 1; i < messageParts.length; i++) { for (let i = 1; i < messageParts.length; i++) {
const messagePart = messageParts[i]; const messagePart = messageParts[i];
const expression = expressions[i - 1]; const expression = expressions[i - 1];
@ -66,16 +79,16 @@ export function parseMessage(
// This should be OK because synthesized nodes only come from the template compiler and they // This should be OK because synthesized nodes only come from the template compiler and they
// will always contain placeholder name information. // will always contain placeholder name information.
// So there will be no escaped placeholder marker character (`:`) directly after a substitution. // So there will be no escaped placeholder marker character (`:`) directly after a substitution.
if ((messageParts.raw[i] || messagePart).charAt(0) === PLACEHOLDER_NAME_MARKER) { if ((messageParts.raw[i] || messagePart).charAt(0) === BLOCK_MARKER) {
const endOfPlaceholderName = messagePart.indexOf(PLACEHOLDER_NAME_MARKER, 1); const endOfPlaceholderName = messagePart.indexOf(BLOCK_MARKER, 1);
const placeholderName = messagePart.substring(1, endOfPlaceholderName); const placeholderName = messagePart.substring(1, endOfPlaceholderName);
translationKey += `{$${placeholderName}}${messagePart.substring(endOfPlaceholderName + 1)}`; messageId += `{$${placeholderName}}${messagePart.substring(endOfPlaceholderName + 1)}`;
replacements[placeholderName] = expression; replacements[placeholderName] = expression;
} else { } else {
const placeholderName = `ph_${i}`; const placeholderName = `ph_${i}`;
translationKey += `{$${placeholderName}}${messagePart}`; messageId += `{$${placeholderName}}${messagePart}`;
replacements[placeholderName] = expression; replacements[placeholderName] = expression;
} }
} }
return {translationKey, substitutions: replacements}; return {messageId: messageId, substitutions: replacements};
} }

View File

@ -5,22 +5,8 @@
* Use of this source code is governed by an MIT-style license that can be * Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {PLACEHOLDER_NAME_MARKER} from './constants'; import {BLOCK_MARKER} from './constants';
import {SourceMessage, parseMessage} from './messages'; import {MessageId, TargetMessage, parseMessage} from './messages';
/**
* A key used to lookup a `TargetMessage` in a hash map.
*/
export type TranslationKey = SourceMessage;
/**
* A string containing a translation target message.
*
* I.E. the message that indicates what will be translated to.
*
* Uses `{$placeholder-name}` to indicate a placeholder.
*/
export type TargetMessage = string;
/** /**
* A translation message that has been processed to extract the message parts and placeholders. * A translation message that has been processed to extract the message parts and placeholders.
@ -33,14 +19,14 @@ export interface ParsedTranslation {
/** /**
* The internal structure used by the runtime localization to translate messages. * The internal structure used by the runtime localization to translate messages.
*/ */
export type ParsedTranslations = Record<TranslationKey, ParsedTranslation>; export type ParsedTranslations = Record<MessageId, ParsedTranslation>;
/** /**
* Translate the text of the `$localize` tagged-string (i.e. `messageParts` and * Translate the text of the `$localize` tagged-string (i.e. `messageParts` and
* `substitutions`) using the given `translations`. * `substitutions`) using the given `translations`.
* *
* The tagged-string is parsed to extract its `translationKey` which is used to find an appropriate * The tagged-string is parsed to extract its `messageId` which is used to find an appropriate
* `ParsedTranslation`. * `ParsedTranslation`.
* *
* If one is found then it is used to translate the message into a new set of `messageParts` and * If one is found then it is used to translate the message into a new set of `messageParts` and
@ -53,7 +39,7 @@ export function translate(
translations: Record<string, ParsedTranslation>, messageParts: TemplateStringsArray, translations: Record<string, ParsedTranslation>, messageParts: TemplateStringsArray,
substitutions: readonly any[]): [TemplateStringsArray, readonly any[]] { substitutions: readonly any[]): [TemplateStringsArray, readonly any[]] {
const message = parseMessage(messageParts, substitutions); const message = parseMessage(messageParts, substitutions);
const translation = translations[message.translationKey]; const translation = translations[message.messageId];
if (translation !== undefined) { if (translation !== undefined) {
return [ return [
translation.messageParts, translation.messageParts,
@ -81,7 +67,7 @@ export function parseTranslation(message: TargetMessage): ParsedTranslation {
messageParts.push(`${parts[i + 1]}`); messageParts.push(`${parts[i + 1]}`);
} }
const rawMessageParts = const rawMessageParts =
messageParts.map(part => part.charAt(0) === PLACEHOLDER_NAME_MARKER ? '\\' + part : part); messageParts.map(part => part.charAt(0) === BLOCK_MARKER ? '\\' + part : part);
return {messageParts: makeTemplateObject(messageParts, rawMessageParts), placeholderNames}; return {messageParts: makeTemplateObject(messageParts, rawMessageParts), placeholderNames};
} }

View File

@ -13,24 +13,24 @@ describe('messages utils', () => {
it('should compute the translation key', () => { it('should compute the translation key', () => {
const message = parseMessage( const message = parseMessage(
makeTemplateObject(['a', ':one:b', ':two:c'], ['a', ':one:b', ':two:c']), [1, 2]); makeTemplateObject(['a', ':one:b', ':two:c'], ['a', ':one:b', ':two:c']), [1, 2]);
expect(message.translationKey).toEqual('a{$one}b{$two}c'); expect(message.messageId).toEqual('a{$one}b{$two}c');
}); });
it('should compute the translation key, inferring placeholder names if not given', () => { it('should compute the translation key, inferring placeholder names if not given', () => {
const message = parseMessage(makeTemplateObject(['a', 'b', 'c'], ['a', 'b', 'c']), [1, 2]); const message = parseMessage(makeTemplateObject(['a', 'b', 'c'], ['a', 'b', 'c']), [1, 2]);
expect(message.translationKey).toEqual('a{$ph_1}b{$ph_2}c'); expect(message.messageId).toEqual('a{$ph_1}b{$ph_2}c');
}); });
it('should compute the translation key, ignoring escaped placeholder names', () => { it('should compute the translation key, ignoring escaped placeholder names', () => {
const message = parseMessage( const message = parseMessage(
makeTemplateObject(['a', ':one:b', ':two:c'], ['a', '\\:one:b', '\\:two:c']), [1, 2]); makeTemplateObject(['a', ':one:b', ':two:c'], ['a', '\\:one:b', '\\:two:c']), [1, 2]);
expect(message.translationKey).toEqual('a{$ph_1}:one:b{$ph_2}:two:c'); expect(message.messageId).toEqual('a{$ph_1}:one:b{$ph_2}:two:c');
}); });
it('should compute the translation key, handling empty raw values', () => { it('should compute the translation key, handling empty raw values', () => {
const message = const message =
parseMessage(makeTemplateObject(['a', ':one:b', ':two:c'], ['', '', '']), [1, 2]); parseMessage(makeTemplateObject(['a', ':one:b', ':two:c'], ['', '', '']), [1, 2]);
expect(message.translationKey).toEqual('a{$one}b{$two}c'); expect(message.messageId).toEqual('a{$one}b{$two}c');
}); });
it('should build a map of named placeholders to expressions', () => { it('should build a map of named placeholders to expressions', () => {

View File

@ -5,7 +5,8 @@
* Use of this source code is governed by an MIT-style license that can be * Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {ParsedTranslation, TargetMessage, TranslationKey, makeTemplateObject, parseTranslation, translate} from '../../src/utils/translations'; import {TargetMessage} from '@angular/localize/src/utils/messages';
import {ParsedTranslation, makeTemplateObject, parseTranslation, translate} from '../../src/utils/translations';
describe('utils', () => { describe('utils', () => {
describe('makeTemplateObject', () => { describe('makeTemplateObject', () => {
@ -146,7 +147,7 @@ describe('utils', () => {
return [messageParts, substitutions]; return [messageParts, substitutions];
} }
function parseTranslations(translations: Record<TranslationKey, TargetMessage>): function parseTranslations(translations: Record<string, TargetMessage>):
Record<string, ParsedTranslation> { Record<string, ParsedTranslation> {
const parsedTranslations: Record<string, ParsedTranslation> = {}; const parsedTranslations: Record<string, ParsedTranslation> = {};
Object.keys(translations).forEach(key => { Object.keys(translations).forEach(key => {
@ -156,7 +157,7 @@ describe('utils', () => {
} }
function doTranslate( function doTranslate(
translations: Record<string, string>, translations: Record<string, TargetMessage>,
message: [TemplateStringsArray, any[]]): [TemplateStringsArray, readonly any[]] { message: [TemplateStringsArray, any[]]): [TemplateStringsArray, readonly any[]] {
return translate(parseTranslations(translations), message[0], message[1]); return translate(parseTranslations(translations), message[0], message[1]);
} }