Skip to content

Commit 38858e4

Browse files
rahul-kamatcopybara-github
authored andcommitted
Add support for ICU templates with placeholders in JSCompiler.
This CL fixes a JSCompiler bug where `declareIcuTemplate` messages that have multiple parts are not translated to other languages. ICU messages created using `declareIcuTemplate` can get stored into the XMB file as multiple parts if necessary to record example or original code text, but those parts are lost and only the original text template string is recorded when we replace the original declarations with protected versions early in compilation. This means we don't have the full information needed to re-calculate the message ID when doing the final message replacement. As a result we had a bug where the wrong ID was calculated and the translation could not be found. This change solves this problem by explicitly recording the correctly calculated message ID in the protected message. PiperOrigin-RevId: 700089624
1 parent 8e18623 commit 38858e4

File tree

2 files changed

+140
-21
lines changed

2 files changed

+140
-21
lines changed

src/com/google/javascript/jscomp/ReplaceMessages.java

+85-11
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
import com.google.javascript.jscomp.JsMessage.Part;
3030
import com.google.javascript.jscomp.JsMessage.PlaceholderFormatException;
3131
import com.google.javascript.jscomp.JsMessage.StringPart;
32+
import com.google.javascript.jscomp.JsMessageVisitor.ExtractedIcuTemplateParts;
33+
import com.google.javascript.jscomp.JsMessageVisitor.IcuMessageTemplateString;
3234
import com.google.javascript.jscomp.JsMessageVisitor.MalformedException;
3335
import com.google.javascript.jscomp.NodeTraversal.AbstractPostOrderCallback;
3436
import com.google.javascript.rhino.Node;
@@ -276,6 +278,20 @@ public boolean unescapeHtmlEntities() {
276278
private Node createMsgPropertiesNode(JsMessage message, MsgOptions msgOptions) {
277279
QuotedKeyObjectLitBuilder msgPropsBuilder = new QuotedKeyObjectLitBuilder();
278280
msgPropsBuilder.addString("key", message.getKey());
281+
if (msgOptions.isIcuTemplate() && !message.canonicalPlaceholderNames().isEmpty()) {
282+
// ICU messages created using `declareIcuTemplate` can get stored into the XMB file as
283+
// multiple parts if necessary to record example or original code text.
284+
// `icu_placeholder_names` stores these parts of the ICU message, which allows us to
285+
// correctly calculate the message ID in the protected message.
286+
final Node namesArrayLit = astFactory.createArraylit();
287+
for (String name : message.canonicalPlaceholderNames()) {
288+
namesArrayLit.addChildToBack(astFactory.createString(name));
289+
}
290+
// Example:
291+
// declareIcuTemplate('blah blah {PH1} blah {PH2}', ... );
292+
// icu_placeholder_names: ['PH1', 'PH2']
293+
msgPropsBuilder.addNode("icu_placeholder_names", namesArrayLit);
294+
}
279295
String altId = message.getAlternateId();
280296
if (altId != null) {
281297
msgPropsBuilder.addString("alt_id", altId);
@@ -882,12 +898,41 @@ private ProtectedJsMessage(
882898
checkState(propertiesNode.isObjectLit(), propertiesNode);
883899
String msgKey = null;
884900
String meaning = null;
901+
Set<String> icuPlaceholderNames = new LinkedHashSet<>();
902+
String messageText = null;
903+
Node messageTextNode = null;
885904
for (Node strKey = propertiesNode.getFirstChild();
886905
strKey != null;
887906
strKey = strKey.getNext()) {
888907
checkState(strKey.isStringKey(), strKey);
889908
String key = strKey.getString();
890909
Node valueNode = strKey.getOnlyChild();
910+
if (key.equals("icu_placeholder_names")) {
911+
checkState(valueNode.isArrayLit(), "icu_placeholder_names must be an array");
912+
// If the message is an ICU template and `icu_placeholder_names` is present, then there
913+
// are placeholders in the message. These placeholders will be replaced at runtime, but
914+
// it is important that we keep track of these placeholders because it means that the
915+
// ICU template CANNOT be treated as a single string part, because having placeholders
916+
// means that the message has multiple parts.
917+
// When a message is a `declareIcuTemplate` with multiple parts, we generate a `msg id`
918+
// in the XMB, which is sent to the Translation Console so that the translators can
919+
// translate this. We generate the deterministic `msg id` using an algorithm that takes
920+
// into account how many parts the message has.
921+
// Now during JSCompiler compilation process, we protect the message by wrapping it in a
922+
// `__jscomp_define_msg__` (for safety because we don't want any of our optimization
923+
// passes to change the message).
924+
// Later in this method, we generate an ID (using `idGenerator.generateId()`) and use
925+
// this to lookup a message in the translated XTB file. As I mentioned earlier, the
926+
// algorithm for generating an ID needs to know the correct parts of the message, so we
927+
// fail to generate the same ID we did when we added the message to the XMB.
928+
// This `icu_placeholder_names` field is necessary to help us figure out the correct
929+
// parts of the message, in order to generate the correct message ID that matches the ID
930+
// we generated when we added the message to the XMB (which is the same ID in the XTB).
931+
for (Node valueNodeChild : valueNode.children()) {
932+
icuPlaceholderNames.add(valueNodeChild.getString());
933+
}
934+
continue;
935+
}
891936
checkState(valueNode.isStringLit(), valueNode);
892937
String value = valueNode.getString();
893938
switch (key) {
@@ -903,17 +948,13 @@ private ProtectedJsMessage(
903948
jsMessageBuilder.setAlternateId(value);
904949
break;
905950
case "msg_text":
906-
try {
907-
// NOTE: If the text is for an ICU template, then it will not contain any
908-
// placeholders ("{$placeholderName}"), so it will be treated as a single string
909-
// part.
910-
jsMessageBuilder.appendParts(JsMessageVisitor.parseJsMessageTextIntoParts(value));
911-
} catch (PlaceholderFormatException unused) {
912-
// Somehow we stored the protected message text incorrectly, which should never
913-
// happen.
914-
throw new IllegalStateException(
915-
valueNode.getLocation() + ": Placeholder incorrectly formatted: >" + value + "<");
916-
}
951+
// This may be an ICU template that also has the `icu_placeholder_names` property, which
952+
// means we need to append multiple parts of the message to `jsMessageBuilder`. For now,
953+
// we will save the message text and current node, and we'll parse it once we know if
954+
// this is an ICU template with multiple parts (after this loop to run through all the
955+
// properties is finished).
956+
messageText = value;
957+
messageTextNode = valueNode;
917958
break;
918959
case "isIcuTemplate":
919960
isIcuTemplate = true;
@@ -930,6 +971,39 @@ private ProtectedJsMessage(
930971
throw new IllegalStateException("unknown protected message key: " + strKey);
931972
}
932973
}
974+
975+
try {
976+
if (!icuPlaceholderNames.isEmpty()) {
977+
checkState(
978+
isIcuTemplate,
979+
"Found icu_placeholder_names for a message that is not an ICU template.");
980+
// This is an ICU template with placeholders ("{$placeholderName}"). We cannot treat this
981+
// as a single string part, because it has multiple parts. Otherwise, we will generate the
982+
// wrong message id and we will not be able to find the correct translated message in the
983+
// XTB file (because when the XMB message was created during message extraction, we
984+
// treated this ICU template as having multiple parts).
985+
final IcuMessageTemplateString icuMessageTemplateString =
986+
new IcuMessageTemplateString(messageText);
987+
final ExtractedIcuTemplateParts extractedIcuTemplateParts =
988+
icuMessageTemplateString.extractParts(icuPlaceholderNames);
989+
990+
// Append the parts of the ICU template to the jsMessageBuilder.
991+
jsMessageBuilder.appendParts(extractedIcuTemplateParts.extractedParts);
992+
} else {
993+
// This message is a single string part. It may be an ICU template without placeholders,
994+
// or it may be a normal `goog.getMsg()` message.
995+
jsMessageBuilder.appendParts(JsMessageVisitor.parseJsMessageTextIntoParts(messageText));
996+
}
997+
} catch (PlaceholderFormatException unused) {
998+
// Somehow we stored the protected message text incorrectly, which should never
999+
// happen 🙏
1000+
throw new IllegalStateException(
1001+
messageTextNode.getLocation()
1002+
+ ": Placeholder incorrectly formatted: >"
1003+
+ messageText
1004+
+ "<");
1005+
}
1006+
9331007
final String externalMessageId = JsMessageVisitor.getExternalMessageId(msgKey);
9341008
if (externalMessageId != null) {
9351009
// MSG_EXTERNAL_12345 = ...

test/com/google/javascript/jscomp/ReplaceMessagesTest.java

+55-10
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
import java.util.List;
3232
import java.util.Map;
3333
import org.junit.Before;
34-
import org.junit.Ignore;
3534
import org.junit.Test;
3635
import org.junit.runner.RunWith;
3736
import org.junit.runners.JUnit4;
@@ -328,8 +327,10 @@ public void testReplaceExternalMessage() {
328327
}
329328

330329
@Test
331-
@Ignore // TODO(b/378574591): fix the bug this test exposes
332330
public void testReplaceIcuTemplateMessageWithBundleAndJsPlaceholders() {
331+
// This unit test contains an ICU template with placeholders ("{EMAIL}"). We cannot treat this
332+
// message as a single string part, because it has multiple parts. Otherwise, we will generate
333+
// the wrong message id and this unit test will fail.
333334
useTestIdGenerator = true;
334335
strictReplacement = true;
335336

@@ -370,16 +371,11 @@ public void testReplaceIcuTemplateMessageWithBundleAndJsPlaceholders() {
370371
lines(
371372
"const {declareIcuTemplate} = goog.require('goog.i18n.messages');",
372373
"",
373-
// TODO(b/378574591): The information gathered here isn't enough to correctly generate
374-
// the message ID for lookup. Since the "example" information is dropped, we won't
375-
// know that we need to split the message text into parts, treating "EMAIL" as a
376-
// placeholder. As a result, we calculate the message ID using just a single string
377-
// part, so we get a different message ID than we got during extraction, and we
378-
// won't be able to find the message.
379374
"const MSG_SHOW_EMAIL =",
380375
" __jscomp_define_msg__(",
381376
" {",
382377
" \"key\": \"MSG_SHOW_EMAIL\",",
378+
" \"icu_placeholder_names\": [\"EMAIL\"],",
383379
" \"msg_text\": \"Email: {EMAIL}\",",
384380
" \"isIcuTemplate\": \"\"",
385381
" });"),
@@ -430,6 +426,50 @@ public void testReplaceIcuTemplateMessageWithoutJsPlaceholders() {
430426
"const MSG_SHOW_EMAIL = 'Retpoŝtadreso: {EMAIL}';"));
431427
}
432428

429+
@Test
430+
public void testReplaceIcuTemplateMessageWithJsPlaceholders() {
431+
// Make sure ICU messages with multiple parts are handled correctly.
432+
// We cannot treat this ICU message as a single string part, because it has two placeholders
433+
// (EMAIL1 and EMAIL2) that cause the message to be split into multiple parts.
434+
registerMessage(
435+
getTestMessageBuilder("MSG_SHOW_EMAIL")
436+
.appendStringPart("Retpoŝtadreso: ")
437+
.appendCanonicalPlaceholderReference("EMAIL_1")
438+
.appendStringPart(" aŭ ")
439+
.appendCanonicalPlaceholderReference("EMAIL_2")
440+
.build());
441+
442+
multiPhaseTest(
443+
lines(
444+
"const {declareIcuTemplate} = goog.require('goog.i18n.messages');",
445+
"",
446+
"const MSG_SHOW_EMAIL =",
447+
" declareIcuTemplate(",
448+
" 'Email Options: {EMAIL_1} or {EMAIL_2}',",
449+
" {",
450+
" description: 'Labeled email address',",
451+
" example: {",
452+
" 'EMAIL_1': '[email protected]',",
453+
" 'EMAIL_2': '[email protected]'",
454+
" }",
455+
" });"),
456+
lines(
457+
"const {declareIcuTemplate} = goog.require('goog.i18n.messages');",
458+
"",
459+
"const MSG_SHOW_EMAIL =",
460+
" __jscomp_define_msg__(",
461+
" {",
462+
" \"key\": \"MSG_SHOW_EMAIL\",",
463+
" \"icu_placeholder_names\": [\"EMAIL_1\", \"EMAIL_2\"],",
464+
" \"msg_text\": \"Email Options: {EMAIL_1} or {EMAIL_2}\",",
465+
" \"isIcuTemplate\": \"\"",
466+
" });"),
467+
lines(
468+
"const {declareIcuTemplate} = goog.require('goog.i18n.messages');",
469+
"",
470+
"const MSG_SHOW_EMAIL = 'Retpoŝtadreso: {EMAIL_1} aŭ {EMAIL_2}';"));
471+
}
472+
433473
@Test
434474
public void testMissingIcuTemplateMessage() {
435475
// We don't registerMessage() here, so there are no messages in the bundle used by this test.
@@ -446,8 +486,12 @@ public void testMissingIcuTemplateMessage() {
446486
// The example text is dropped, since it is only used for XMB extraction.
447487
// However, it does cause the JsMessage read from the JS code to have a placeholder
448488
// in it.
449-
// One purpose of this test is to make sure the message template is properly put back
450-
// together.
489+
// We add this placeholder in the "icu_placeholder_names" field to keep track of how the
490+
// message has multiple parts, which is necessary for the message ID to be generated
491+
// correctly.
492+
// The purpose of this test is to:
493+
// 1. make sure the message template is properly put back together.
494+
// 2. make sure the "icu_placeholder_names" field is populated with `EMAIL`
451495
" example: {",
452496
" 'EMAIL': '[email protected]'",
453497
" }",
@@ -459,6 +503,7 @@ public void testMissingIcuTemplateMessage() {
459503
" __jscomp_define_msg__(",
460504
" {",
461505
" \"key\": \"MSG_SHOW_EMAIL\",",
506+
" \"icu_placeholder_names\": [\"EMAIL\"],",
462507
" \"msg_text\": \"Email: {EMAIL}\",",
463508
" \"isIcuTemplate\": \"\"",
464509
" });"),

0 commit comments

Comments
 (0)