refactor(localize): consolidate message/translation metadata (#36745)

PR Close #36745
This commit is contained in:
Pete Bacon Darwin 2020-04-20 11:53:31 +01:00 committed by Matias Niemelä
parent 519f2baff0
commit b7acf07a70
6 changed files with 118 additions and 82 deletions

View File

@ -8,4 +8,4 @@
// This file exports all the `utils` as private exports so that other parts of `@angular/localize` // This file exports all the `utils` as private exports so that other parts of `@angular/localize`
// can make use of them. // can make use of them.
export {computeMsgId as ɵcomputeMsgId, findEndOfBlock as ɵfindEndOfBlock, isMissingTranslationError as ɵisMissingTranslationError, makeParsedTranslation as ɵmakeParsedTranslation, makeTemplateObject as ɵmakeTemplateObject, MessageId as ɵMessageId, MissingTranslationError as ɵMissingTranslationError, ParsedMessage as ɵParsedMessage, ParsedTranslation as ɵParsedTranslation, ParsedTranslations as ɵParsedTranslations, parseMessage as ɵparseMessage, parseMetadata as ɵparseMetadata, parseTranslation as ɵparseTranslation, SourceMessage as ɵSourceMessage, splitBlock as ɵsplitBlock, TargetMessage as ɵTargetMessage, translate as ɵtranslate} from './src/utils'; export {computeMsgId as ɵcomputeMsgId, findEndOfBlock as ɵfindEndOfBlock, isMissingTranslationError as ɵisMissingTranslationError, makeParsedTranslation as ɵmakeParsedTranslation, makeTemplateObject as ɵmakeTemplateObject, MessageId as ɵMessageId, MissingTranslationError as ɵMissingTranslationError, ParsedMessage as ɵParsedMessage, ParsedTranslation as ɵParsedTranslation, ParsedTranslations as ɵParsedTranslations, parseMessage as ɵparseMessage, parseMetadata as ɵparseMetadata, parseTranslation as ɵparseTranslation, SourceLocation as ɵSourceLocation, SourceMessage as ɵSourceMessage, splitBlock as ɵsplitBlock, TargetMessage as ɵTargetMessage, translate as ɵtranslate} from './src/utils';

View File

@ -5,7 +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 {ɵisMissingTranslationError, ɵmakeTemplateObject, ɵParsedTranslation, ɵtranslate} from '@angular/localize'; import {ɵisMissingTranslationError, ɵmakeTemplateObject, ɵParsedTranslation, ɵSourceLocation, ɵtranslate} from '@angular/localize';
import {NodePath} from '@babel/traverse'; import {NodePath} from '@babel/traverse';
import * as t from '@babel/types'; import * as t from '@babel/types';
import {Diagnostics} from './diagnostics'; import {Diagnostics} from './diagnostics';
@ -354,3 +354,16 @@ export function buildCodeFrameError(path: NodePath, e: BabelParseError): string
const message = path.hub.file.buildCodeFrameError(e.node, e.message).message; const message = path.hub.file.buildCodeFrameError(e.node, e.message).message;
return `${filename}: ${message}`; return `${filename}: ${message}`;
} }
export function getLocation(path: NodePath): ɵSourceLocation|undefined {
const location = path.node.loc;
const file = path.hub.file.ops.fileName;
if (!location || !file) {
return undefined;
}
// Note we clone the `start` and `end` objects so that their prototype chains,
// from Babel, do not leak into our code.
return {start: {...location.start}, end: {...location.end}, file};
}

View File

@ -53,6 +53,7 @@ describe('SimpleJsonTranslationParser', () => {
}`); }`);
expect(result.translations).toEqual({ expect(result.translations).toEqual({
'Hello, {$ph_1}!': { 'Hello, {$ph_1}!': {
text: 'Bonjour, {$ph_1}!',
messageParts: ɵmakeTemplateObject(['Bonjour, ', '!'], ['Bonjour, ', '!']), messageParts: ɵmakeTemplateObject(['Bonjour, ', '!'], ['Bonjour, ', '!']),
placeholderNames: ['ph_1'] placeholderNames: ['ph_1']
}, },

View File

@ -38,6 +38,52 @@ export type TargetMessage = string;
*/ */
export type MessageId = string; export type MessageId = string;
/**
* The location of the message
*/
export interface SourceLocation {
start: {line: number, column: number};
end: {line: number, column: number};
file: string;
}
/**
* Additional information that can be associated with a message.
*/
export interface MessageMetadata {
/**
* A human readable rendering of the message
*/
text: string;
/**
* A unique identifier for this message.
*/
id?: MessageId;
/**
* Legacy message ids, if provided.
*
* In legacy message formats the message id can only be computed directly from the original
* template source.
*
* Since this information is not available in `$localize` calls, the legacy message ids may be
* attached by the compiler to the `$localize` metablock so it can be used if needed at the point
* of translation if the translations are encoded using the legacy message id.
*/
legacyIds?: string[];
/**
* The meaning of the `message`, used to distinguish identical `messageString`s.
*/
meaning?: string;
/**
* The description of the `message`, used to aid translation.
*/
description?: string;
/**
* The location of the message in the source.
*/
location?: SourceLocation;
}
/** /**
* 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.
* *
@ -52,44 +98,23 @@ export type MessageId = string;
* *
* ``` * ```
* { * {
* messageId: '6998194507597730591', * id: '6998194507597730591',
* substitutions: { title: 'Jo Bloggs' }, * substitutions: { title: 'Jo Bloggs' },
* messageString: 'Hello {$title}!', * messageString: 'Hello {$title}!',
* } * }
* ``` * ```
*/ */
export interface ParsedMessage { export interface ParsedMessage extends MessageMetadata {
/** /**
* The key used to look up the appropriate translation target. * The key used to look up the appropriate translation target.
*/
messageId: MessageId;
/**
* Legacy message ids, if provided.
* *
* In legacy message formats the message id can only be computed directly from the original * In `ParsedMessage` this is a required field, whereas it is optional in `MessageMetadata`.
* template source.
*
* Since this information is not available in `$localize` calls, the legacy message ids may be
* attached by the compiler to the `$localize` metablock so it can be used if needed at the point
* of translation if the translations are encoded using the legacy message id.
*/ */
legacyIds: MessageId[]; id: MessageId;
/** /**
* A mapping of placeholder names to substitution values. * A mapping of placeholder names to substitution values.
*/ */
substitutions: Record<string, any>; substitutions: Record<string, any>;
/**
* A human readable rendering of the message
*/
messageString: string;
/**
* The meaning of the `message`, used to distinguish identical `messageString`s.
*/
meaning: string;
/**
* The description of the `message`, used to aid translation.
*/
description: string;
/** /**
* The static parts of the message. * The static parts of the message.
*/ */
@ -106,7 +131,8 @@ export interface ParsedMessage {
* See `ParsedMessage` for an example. * See `ParsedMessage` for an example.
*/ */
export function parseMessage( export function parseMessage(
messageParts: TemplateStringsArray, expressions?: readonly any[]): ParsedMessage { messageParts: TemplateStringsArray, expressions?: readonly any[],
location?: SourceLocation): ParsedMessage {
const substitutions: {[placeholderName: string]: any} = {}; const substitutions: {[placeholderName: string]: any} = {};
const metadata = parseMetadata(messageParts[0], messageParts.raw[0]); const metadata = parseMetadata(messageParts[0], messageParts.raw[0]);
const cleanedMessageParts: string[] = [metadata.text]; const cleanedMessageParts: string[] = [metadata.text];
@ -123,27 +149,20 @@ export function parseMessage(
cleanedMessageParts.push(messagePart); cleanedMessageParts.push(messagePart);
} }
const messageId = metadata.id || computeMsgId(messageString, metadata.meaning || ''); const messageId = metadata.id || computeMsgId(messageString, metadata.meaning || '');
const legacyIds = metadata.legacyIds.filter(id => id !== messageId); const legacyIds = metadata.legacyIds && metadata.legacyIds.filter(id => id !== messageId);
return { return {
messageId, id: messageId,
legacyIds, legacyIds,
substitutions, substitutions,
messageString, text: messageString,
meaning: metadata.meaning || '', meaning: metadata.meaning || '',
description: metadata.description || '', description: metadata.description || '',
messageParts: cleanedMessageParts, messageParts: cleanedMessageParts,
placeholderNames, placeholderNames,
location,
}; };
} }
export interface MessageMetadata {
text: string;
meaning: string|undefined;
description: string|undefined;
id: string|undefined;
legacyIds: string[];
}
/** /**
* Parse the given message part (`cooked` + `raw`) to extract the message metadata from the text. * Parse the given message part (`cooked` + `raw`) to extract the message metadata from the text.
* *
@ -171,9 +190,9 @@ export interface MessageMetadata {
* @returns A object containing any metadata that was parsed from the message part. * @returns A object containing any metadata that was parsed from the message part.
*/ */
export function parseMetadata(cooked: string, raw: string): MessageMetadata { export function parseMetadata(cooked: string, raw: string): MessageMetadata {
const {text, block} = splitBlock(cooked, raw); const {text: messageString, block} = splitBlock(cooked, raw);
if (block === undefined) { if (block === undefined) {
return {text, meaning: undefined, description: undefined, id: undefined, legacyIds: []}; return {text: messageString};
} else { } else {
const [meaningDescAndId, ...legacyIds] = block.split(LEGACY_ID_INDICATOR); const [meaningDescAndId, ...legacyIds] = block.split(LEGACY_ID_INDICATOR);
const [meaningAndDesc, id] = meaningDescAndId.split(ID_SEPARATOR, 2); const [meaningAndDesc, id] = meaningDescAndId.split(ID_SEPARATOR, 2);
@ -185,7 +204,7 @@ export function parseMetadata(cooked: string, raw: string): MessageMetadata {
if (description === '') { if (description === '') {
description = undefined; description = undefined;
} }
return {text, meaning, description, id, legacyIds}; return {text: messageString, meaning, description, id, legacyIds};
} }
} }

View File

@ -6,13 +6,13 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {BLOCK_MARKER} from './constants'; import {BLOCK_MARKER} from './constants';
import {MessageId, ParsedMessage, parseMessage, TargetMessage} from './messages'; import {MessageId, MessageMetadata, ParsedMessage, parseMessage, TargetMessage} from './messages';
/** /**
* 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.
*/ */
export interface ParsedTranslation { export interface ParsedTranslation extends MessageMetadata {
messageParts: TemplateStringsArray; messageParts: TemplateStringsArray;
placeholderNames: string[]; placeholderNames: string[];
} }
@ -54,10 +54,12 @@ export function translate(
substitutions: readonly any[]): [TemplateStringsArray, readonly any[]] { substitutions: readonly any[]): [TemplateStringsArray, readonly any[]] {
const message = parseMessage(messageParts, substitutions); const message = parseMessage(messageParts, substitutions);
// Look up the translation using the messageId, and then the legacyId if available. // Look up the translation using the messageId, and then the legacyId if available.
let translation = translations[message.messageId]; let translation = translations[message.id];
// If the messageId did not match a translation, try matching the legacy ids instead // If the messageId did not match a translation, try matching the legacy ids instead
for (let i = 0; i < message.legacyIds.length && translation === undefined; i++) { if (message.legacyIds !== undefined) {
translation = translations[message.legacyIds[i]]; for (let i = 0; i < message.legacyIds.length && translation === undefined; i++) {
translation = translations[message.legacyIds[i]];
}
} }
if (translation === undefined) { if (translation === undefined) {
throw new MissingTranslationError(message); throw new MissingTranslationError(message);
@ -85,8 +87,8 @@ export function translate(
* *
* @param message the message to be parsed. * @param message the message to be parsed.
*/ */
export function parseTranslation(message: TargetMessage): ParsedTranslation { export function parseTranslation(messageString: TargetMessage): ParsedTranslation {
const parts = message.split(/{\$([^}]*)}/); const parts = messageString.split(/{\$([^}]*)}/);
const messageParts = [parts[0]]; const messageParts = [parts[0]];
const placeholderNames: string[] = []; const placeholderNames: string[] = [];
for (let i = 1; i < parts.length - 1; i += 2) { for (let i = 1; i < parts.length - 1; i += 2) {
@ -95,7 +97,11 @@ export function parseTranslation(message: TargetMessage): ParsedTranslation {
} }
const rawMessageParts = const rawMessageParts =
messageParts.map(part => part.charAt(0) === BLOCK_MARKER ? '\\' + part : part); messageParts.map(part => part.charAt(0) === BLOCK_MARKER ? '\\' + part : part);
return {messageParts: makeTemplateObject(messageParts, rawMessageParts), placeholderNames}; return {
text: messageString,
messageParts: makeTemplateObject(messageParts, rawMessageParts),
placeholderNames,
};
} }
/** /**
@ -106,7 +112,15 @@ export function parseTranslation(message: TargetMessage): ParsedTranslation {
*/ */
export function makeParsedTranslation( export function makeParsedTranslation(
messageParts: string[], placeholderNames: string[] = []): ParsedTranslation { messageParts: string[], placeholderNames: string[] = []): ParsedTranslation {
return {messageParts: makeTemplateObject(messageParts, messageParts), placeholderNames}; let messageString = messageParts[0];
for (let i = 0; i < placeholderNames.length - 1; i++) {
messageString += `{$${placeholderNames[i]}}${messageParts[i + 1]}`;
}
return {
text: messageString,
messageParts: makeTemplateObject(messageParts, messageParts),
placeholderNames
};
} }
/** /**
@ -123,7 +137,8 @@ export function makeTemplateObject(cooked: string[], raw: string[]): TemplateStr
function describeMessage(message: ParsedMessage): string { function describeMessage(message: ParsedMessage): string {
const meaningString = message.meaning && ` - "${message.meaning}"`; const meaningString = message.meaning && ` - "${message.meaning}"`;
const legacy = const legacy = message.legacyIds && message.legacyIds.length > 0 ?
message.legacyIds.length > 0 ? ` [${message.legacyIds.map(l => `"${l}"`).join(', ')}]` : ''; ` [${message.legacyIds.map(l => `"${l}"`).join(', ')}]` :
return `"${message.messageId}"${legacy} ("${message.messageString}"${meaningString})`; '';
return `"${message.id}"${legacy} ("${message.text}"${meaningString})`;
} }

View File

@ -15,13 +15,13 @@ describe('messages utils', () => {
[':@@custom-message-id:a', ':one:b', ':two:c'], [':@@custom-message-id:a', ':one:b', ':two:c'],
[':@@custom-message-id:a', ':one:b', ':two:c']), [':@@custom-message-id:a', ':one:b', ':two:c']),
[1, 2]); [1, 2]);
expect(message.messageId).toEqual('custom-message-id'); expect(message.id).toEqual('custom-message-id');
}); });
it('should compute the translation key if no metadata', () => { it('should compute the translation key if no metadata', () => {
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.messageId).toEqual('8865273085679272414'); expect(message.id).toEqual('8865273085679272414');
}); });
it('should compute the translation key if no id in the metadata', () => { it('should compute the translation key if no id in the metadata', () => {
@ -29,16 +29,16 @@ describe('messages utils', () => {
makeTemplateObject( makeTemplateObject(
[':description:a', ':one:b', ':two:c'], [':description:a', ':one:b', ':two:c']), [':description:a', ':one:b', ':two:c'], [':description:a', ':one:b', ':two:c']),
[1, 2]); [1, 2]);
expect(message.messageId).toEqual('8865273085679272414'); expect(message.id).toEqual('8865273085679272414');
}); });
it('should compute a different id if the meaning changes', () => { it('should compute a different id if the meaning changes', () => {
const message1 = parseMessage(makeTemplateObject(['abc'], ['abc']), []); const message1 = parseMessage(makeTemplateObject(['abc'], ['abc']), []);
const message2 = parseMessage(makeTemplateObject([':meaning1|:abc'], [':meaning1|:abc']), []); const message2 = parseMessage(makeTemplateObject([':meaning1|:abc'], [':meaning1|:abc']), []);
const message3 = parseMessage(makeTemplateObject([':meaning2|:abc'], [':meaning2|:abc']), []); const message3 = parseMessage(makeTemplateObject([':meaning2|:abc'], [':meaning2|:abc']), []);
expect(message1.messageId).not.toEqual(message2.messageId); expect(message1.id).not.toEqual(message2.id);
expect(message2.messageId).not.toEqual(message3.messageId); expect(message2.id).not.toEqual(message3.id);
expect(message3.messageId).not.toEqual(message1.messageId); expect(message3.id).not.toEqual(message1.id);
}); });
it('should capture legacy ids if available', () => { it('should capture legacy ids if available', () => {
@ -47,7 +47,7 @@ describe('messages utils', () => {
[':␟legacy-1␟legacy-2␟legacy-3:a', ':one:b', ':two:c'], [':␟legacy-1␟legacy-2␟legacy-3:a', ':one:b', ':two:c'],
[':␟legacy-1␟legacy-2␟legacy-3:a', ':one:b', ':two:c']), [':␟legacy-1␟legacy-2␟legacy-3:a', ':one:b', ':two:c']),
[1, 2]); [1, 2]);
expect(message1.messageId).toEqual('8865273085679272414'); expect(message1.id).toEqual('8865273085679272414');
expect(message1.legacyIds).toEqual(['legacy-1', 'legacy-2', 'legacy-3']); expect(message1.legacyIds).toEqual(['legacy-1', 'legacy-2', 'legacy-3']);
const message2 = parseMessage( const message2 = parseMessage(
@ -55,7 +55,7 @@ describe('messages utils', () => {
[':@@custom-message-id␟legacy-message-id:a', ':one:b', ':two:c'], [':@@custom-message-id␟legacy-message-id:a', ':one:b', ':two:c'],
[':@@custom-message-id␟legacy-message-id:a', ':one:b', ':two:c']), [':@@custom-message-id␟legacy-message-id:a', ':one:b', ':two:c']),
[1, 2]); [1, 2]);
expect(message2.messageId).toEqual('custom-message-id'); expect(message2.id).toEqual('custom-message-id');
expect(message2.legacyIds).toEqual(['legacy-message-id']); expect(message2.legacyIds).toEqual(['legacy-message-id']);
const message3 = parseMessage( const message3 = parseMessage(
@ -63,28 +63,28 @@ describe('messages utils', () => {
[':@@custom-message-id:a', ':one:b', ':two:c'], [':@@custom-message-id:a', ':one:b', ':two:c'],
[':@@custom-message-id:a', ':one:b', ':two:c']), [':@@custom-message-id:a', ':one:b', ':two:c']),
[1, 2]); [1, 2]);
expect(message3.messageId).toEqual('custom-message-id'); expect(message3.id).toEqual('custom-message-id');
expect(message3.legacyIds).toEqual([]); expect(message3.legacyIds).toEqual([]);
}); });
it('should infer placeholder names if not given', () => { it('should infer placeholder names if not given', () => {
const parts1 = ['a', 'b', 'c']; const parts1 = ['a', 'b', 'c'];
const message1 = parseMessage(makeTemplateObject(parts1, parts1), [1, 2]); const message1 = parseMessage(makeTemplateObject(parts1, parts1), [1, 2]);
expect(message1.messageId).toEqual('8107531564991075946'); expect(message1.id).toEqual('8107531564991075946');
const parts2 = ['a', ':custom1:b', ':custom2:c']; const parts2 = ['a', ':custom1:b', ':custom2:c'];
const message2 = parseMessage(makeTemplateObject(parts2, parts2), [1, 2]); const message2 = parseMessage(makeTemplateObject(parts2, parts2), [1, 2]);
expect(message2.messageId).toEqual('1822117095464505589'); expect(message2.id).toEqual('1822117095464505589');
// Note that the placeholder names are part of the message so affect the message id. // Note that the placeholder names are part of the message so affect the message id.
expect(message1.messageId).not.toEqual(message2.messageId); expect(message1.id).not.toEqual(message2.id);
expect(message1.messageString).not.toEqual(message2.messageString); expect(message1.text).not.toEqual(message2.text);
}); });
it('should ignore placeholder blocks whose markers have been escaped', () => { it('should ignore placeholder blocks whose markers have been escaped', () => {
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.messageId).toEqual('2623373088949454037'); expect(message.id).toEqual('2623373088949454037');
}); });
it('should extract the meaning, description and placeholder names', () => { it('should extract the meaning, description and placeholder names', () => {
@ -173,13 +173,7 @@ describe('messages utils', () => {
describe('parseMetadata()', () => { describe('parseMetadata()', () => {
it('should return just the text if there is no block', () => { it('should return just the text if there is no block', () => {
expect(parseMetadata('abc def', 'abc def')).toEqual({ expect(parseMetadata('abc def', 'abc def')).toEqual({text: 'abc def'});
text: 'abc def',
meaning: undefined,
description: undefined,
id: undefined,
legacyIds: []
});
}); });
it('should extract the metadata if provided', () => { it('should extract the metadata if provided', () => {
@ -279,13 +273,7 @@ describe('messages utils', () => {
it('should handle escaped block markers', () => { it('should handle escaped block markers', () => {
expect(parseMetadata(':part of the message:abc def', '\\:part of the message:abc def')) expect(parseMetadata(':part of the message:abc def', '\\:part of the message:abc def'))
.toEqual({ .toEqual({text: ':part of the message:abc def'});
text: ':part of the message:abc def',
meaning: undefined,
description: undefined,
id: undefined,
legacyIds: []
});
}); });
}); });
}); });