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:
parent
c7abb7d196
commit
870d189433
@ -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;
|
||||||
|
@ -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 = ':';
|
||||||
|
@ -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};
|
||||||
}
|
}
|
||||||
|
@ -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};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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', () => {
|
||||||
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user