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
*/
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.
@ -24,7 +25,7 @@ declare const $localize: LocalizeFn&{TRANSLATIONS: Record<string, ParsedTranslat
*
* @publicApi
*/
export function loadTranslations(translations: Record<TranslationKey, TargetMessage>) {
export function loadTranslations(translations: Record<MessageId, TargetMessage>) {
// Ensure the translate function exists
if (!$localize.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
* string.
* The character used to mark the start and end of a "block" in a `$localize` tagged string.
* A block can indicate metadata about the message or specify a name of a placeholder for a
* substitution expressions.
*
* For example:
*
* ```
* ```ts
* $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
* found in the LICENSE file at https://angular.io/license
*/
import {PLACEHOLDER_NAME_MARKER} from './constants';
import {TranslationKey} from './translations';
import {BLOCK_MARKER} from './constants';
/**
* A string containing a translation source message.
@ -17,6 +16,20 @@ import {TranslationKey} from './translations';
*/
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.
*
@ -31,7 +44,7 @@ export type SourceMessage = string;
*
* ```
* {
* translationKey: 'Hello {$title}!',
* messageId: '6998194507597730591',
* substitutions: { title: 'Jo Bloggs' },
* }
* ```
@ -40,7 +53,7 @@ export interface ParsedMessage {
/**
* The key used to look up the appropriate translation target.
*/
translationKey: TranslationKey;
messageId: MessageId;
/**
* A mapping of placeholder names to substitution values.
*/
@ -55,7 +68,7 @@ export interface ParsedMessage {
export function parseMessage(
messageParts: TemplateStringsArray, expressions: readonly any[]): ParsedMessage {
const replacements: {[placeholderName: string]: any} = {};
let translationKey = messageParts[0];
let messageId = messageParts[0];
for (let i = 1; i < messageParts.length; i++) {
const messagePart = messageParts[i];
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
// will always contain placeholder name information.
// So there will be no escaped placeholder marker character (`:`) directly after a substitution.
if ((messageParts.raw[i] || messagePart).charAt(0) === PLACEHOLDER_NAME_MARKER) {
const endOfPlaceholderName = messagePart.indexOf(PLACEHOLDER_NAME_MARKER, 1);
if ((messageParts.raw[i] || messagePart).charAt(0) === BLOCK_MARKER) {
const endOfPlaceholderName = messagePart.indexOf(BLOCK_MARKER, 1);
const placeholderName = messagePart.substring(1, endOfPlaceholderName);
translationKey += `{$${placeholderName}}${messagePart.substring(endOfPlaceholderName + 1)}`;
messageId += `{$${placeholderName}}${messagePart.substring(endOfPlaceholderName + 1)}`;
replacements[placeholderName] = expression;
} else {
const placeholderName = `ph_${i}`;
translationKey += `{$${placeholderName}}${messagePart}`;
messageId += `{$${placeholderName}}${messagePart}`;
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
* found in the LICENSE file at https://angular.io/license
*/
import {PLACEHOLDER_NAME_MARKER} from './constants';
import {SourceMessage, 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;
import {BLOCK_MARKER} from './constants';
import {MessageId, TargetMessage, parseMessage} from './messages';
/**
* 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.
*/
export type ParsedTranslations = Record<TranslationKey, ParsedTranslation>;
export type ParsedTranslations = Record<MessageId, ParsedTranslation>;
/**
* Translate the text of the `$localize` tagged-string (i.e. `messageParts` and
* `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`.
*
* 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,
substitutions: readonly any[]): [TemplateStringsArray, readonly any[]] {
const message = parseMessage(messageParts, substitutions);
const translation = translations[message.translationKey];
const translation = translations[message.messageId];
if (translation !== undefined) {
return [
translation.messageParts,
@ -81,7 +67,7 @@ export function parseTranslation(message: TargetMessage): ParsedTranslation {
messageParts.push(`${parts[i + 1]}`);
}
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};
}

View File

@ -13,24 +13,24 @@ describe('messages utils', () => {
it('should compute the translation key', () => {
const message = parseMessage(
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', () => {
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', () => {
const message = parseMessage(
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', () => {
const message =
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', () => {

View File

@ -5,7 +5,8 @@
* 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
*/
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('makeTemplateObject', () => {
@ -146,7 +147,7 @@ describe('utils', () => {
return [messageParts, substitutions];
}
function parseTranslations(translations: Record<TranslationKey, TargetMessage>):
function parseTranslations(translations: Record<string, TargetMessage>):
Record<string, ParsedTranslation> {
const parsedTranslations: Record<string, ParsedTranslation> = {};
Object.keys(translations).forEach(key => {
@ -156,7 +157,7 @@ describe('utils', () => {
}
function doTranslate(
translations: Record<string, string>,
translations: Record<string, TargetMessage>,
message: [TemplateStringsArray, any[]]): [TemplateStringsArray, readonly any[]] {
return translate(parseTranslations(translations), message[0], message[1]);
}