Skip to content

Commit 21fa602

Browse files
fix(ui): make Authenticator delivery message punctuation translation-owned (i18n) (#7055)
1 parent be841db commit 21fa602

7 files changed

Lines changed: 401 additions & 2 deletions

File tree

.changeset/gentle-suns-hang.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@aws-amplify/ui': patch
3+
---
4+
5+
fix(ui): render locale-correct punctuation in Authenticator delivery messages (i18n)
6+
7+
`getDeliveryMessageText` joined the translated delivery-message fragments with a
8+
hardcoded ASCII period, producing incorrect punctuation for locales whose
9+
sentence terminator differs — e.g. Japanese and Chinese (which use the
10+
ideographic full stop ``) and Thai (which uses none). The terminator is now
11+
derived from the script of the surrounding translated copy, so each locale
12+
renders its own punctuation.
13+
14+
This is an internal change with no public API impact: no translation keys are
15+
added or changed, the `translate()` signature is unchanged, existing customer
16+
vocabulary overrides on the documented keys keep working, and English output is
17+
byte-identical. Fixes #6966.

packages/e2e/cypress/integration/ui/components/authenticator/i18n/i18n.steps.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,10 @@ Then(
5353
});
5454
}
5555
);
56+
57+
When(
58+
'I type {string} in the {string} input in {string}',
59+
(value: string, label: string, language: string) => {
60+
cy.findByLabelText(translations[language][label].trim()).type(value);
61+
}
62+
);

packages/e2e/features/ui/components/authenticator/i18n.feature

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,19 @@ Feature: Internationalization (I18n)
2424
@angular @react @vue @svelte
2525
Scenario: Authenticator reflects updated vocabularies for `I18n.setLanguage('ja')`
2626
Then I see "Sign In Custom"
27+
28+
# Regression test for #6966: delivery-message punctuation is owned by the
29+
# translation, so a non-Latin locale (Japanese) renders its own terminal
30+
# punctuation (the ideographic full stop 。) after the destination and at the
31+
# end of the message, never a hardcoded ASCII period.
32+
@angular @react @vue @svelte
33+
Scenario: Confirm sign up delivery message uses locale punctuation, not a hardcoded ASCII period
34+
Given I intercept '{ "headers": { "X-Amz-Target": "AWSCognitoIdentityProviderService.SignUp" } }' with fixture "sign-up-with-email"
35+
When I type "e2e-user" in the "Username" input in "ja"
36+
Then I type "e2e-user@example.com" in the "Email" input in "ja"
37+
Then I type "TestPassword123!" in the "Password" input in "ja"
38+
Then I type "TestPassword123!" in the "Confirm Password" input in "ja"
39+
Then the "Create Account" button is in "ja" and I click it
40+
Then I see "送信先: a***@e***.com。"
41+
Then I see "到着するまでに 1 分かかることがあります。"
42+
Then I don't see "a***@e***.com."

packages/ui/src/helpers/authenticator/__tests__/textUtil.test.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
} from '../../../machines/authenticator/types';
55

66
import { authenticatorTextUtil } from '../textUtil';
7+
import { I18n } from 'aws-amplify/utils';
8+
import { translations } from '../../../i18n';
79

810
describe('authenticatorTextUtil', () => {
911
describe('getChallengeText', () => {
@@ -192,3 +194,180 @@ describe('authenticatorTextUtil', () => {
192194
});
193195
});
194196
});
197+
198+
describe('getDeliveryMessageText locale punctuation (#6966)', () => {
199+
const emailDetails = {
200+
DeliveryMedium: 'EMAIL' as V6AuthDeliveryMedium,
201+
Destination: 'user@example.com',
202+
AttributeName: '',
203+
};
204+
const smsDetails = {
205+
DeliveryMedium: 'SMS' as V6AuthDeliveryMedium,
206+
Destination: '+1234567890',
207+
AttributeName: '',
208+
};
209+
const unknownDetails = {
210+
DeliveryMedium: 'INVALID_MEDIUM' as V6AuthDeliveryMedium,
211+
Destination: 'user@example.com',
212+
AttributeName: '',
213+
};
214+
215+
// CJK locales use the ideographic full stop; Thai uses no terminator; every
216+
// other bundled locale (Latin/Cyrillic/Hangul) keeps the ASCII period.
217+
const CJK_LOCALES = ['ja', 'zh'] as const;
218+
const NO_ASCII_PERIOD_LOCALES = ['th'] as const;
219+
const ASCII_PERIOD_LOCALES = [
220+
'de',
221+
'es',
222+
'fr',
223+
'hu',
224+
'id',
225+
'it',
226+
'kr',
227+
'nb',
228+
'nl',
229+
'pl',
230+
'pt',
231+
'ru',
232+
'sv',
233+
'tr',
234+
'ua',
235+
] as const;
236+
237+
beforeEach(() => {
238+
// Restore canonical vocabularies so an override from a prior test cannot leak.
239+
I18n.putVocabularies(translations);
240+
I18n.setLanguage('en');
241+
});
242+
243+
afterAll(() => {
244+
I18n.setLanguage('en');
245+
});
246+
247+
it('renders Japanese email with the ideographic full stop after the destination and at the end, never an ASCII period', () => {
248+
I18n.setLanguage('ja');
249+
const result = authenticatorTextUtil.getDeliveryMessageText(emailDetails);
250+
251+
expect(result).toContain('user@example.com。');
252+
expect(result).not.toContain('user@example.com. ');
253+
expect(result.endsWith('。')).toBe(true);
254+
expect(result.endsWith('.')).toBe(false);
255+
});
256+
257+
it('renders Japanese SMS and unknown mediums terminated with the ideographic full stop', () => {
258+
I18n.setLanguage('ja');
259+
260+
const sms = authenticatorTextUtil.getDeliveryMessageText(smsDetails);
261+
expect(sms).toContain('+1234567890。');
262+
expect(sms.endsWith('。')).toBe(true);
263+
264+
const unknown =
265+
authenticatorTextUtil.getDeliveryMessageText(unknownDetails);
266+
expect(unknown.endsWith('。')).toBe(true);
267+
expect(unknown.endsWith('.')).toBe(false);
268+
});
269+
270+
it.each(CJK_LOCALES)(
271+
'locale "%s" terminates every medium with the ideographic full stop, never an ASCII period',
272+
(locale) => {
273+
I18n.setLanguage(locale);
274+
for (const details of [emailDetails, smsDetails, unknownDetails]) {
275+
const result = authenticatorTextUtil.getDeliveryMessageText(details);
276+
expect(result.endsWith('。')).toBe(true);
277+
expect(result.endsWith('.')).toBe(false);
278+
}
279+
}
280+
);
281+
282+
it.each(NO_ASCII_PERIOD_LOCALES)(
283+
'locale "%s" never terminates a delivery message with an ASCII period',
284+
(locale) => {
285+
I18n.setLanguage(locale);
286+
for (const details of [emailDetails, smsDetails, unknownDetails]) {
287+
const result = authenticatorTextUtil.getDeliveryMessageText(details);
288+
expect(result.endsWith('.')).toBe(false);
289+
expect(result.endsWith('。')).toBe(false);
290+
}
291+
}
292+
);
293+
294+
it.each(ASCII_PERIOD_LOCALES)(
295+
'locale "%s" keeps the ASCII period and does not use the ideographic full stop',
296+
(locale) => {
297+
I18n.setLanguage(locale);
298+
for (const details of [emailDetails, smsDetails, unknownDetails]) {
299+
const result = authenticatorTextUtil.getDeliveryMessageText(details);
300+
expect(result.endsWith('.')).toBe(true);
301+
expect(result.endsWith('。')).toBe(false);
302+
}
303+
}
304+
);
305+
306+
it('keeps English output byte-identical to the pre-fix strings', () => {
307+
I18n.setLanguage('en');
308+
expect(authenticatorTextUtil.getDeliveryMessageText(emailDetails)).toBe(
309+
'Your code is on the way. To log in, enter the code we emailed to user@example.com. It may take a minute to arrive.'
310+
);
311+
expect(authenticatorTextUtil.getDeliveryMessageText(smsDetails)).toBe(
312+
'Your code is on the way. To log in, enter the code we texted to +1234567890. It may take a minute to arrive.'
313+
);
314+
expect(authenticatorTextUtil.getDeliveryMessageText(unknownDetails)).toBe(
315+
'Your code is on the way. To log in, enter the code we sent you. It may take a minute to arrive.'
316+
);
317+
});
318+
319+
it('honors customer vocabulary overrides on the documented keys (confirm-sign-up pattern)', () => {
320+
I18n.putVocabulariesForLanguage('en', {
321+
'Your code is on the way. To log in, enter the code we emailed to':
322+
'Enter this code:',
323+
'It may take a minute to arrive':
324+
'It will take several minutes to arrive',
325+
});
326+
327+
expect(authenticatorTextUtil.getDeliveryMessageText(emailDetails)).toBe(
328+
'Enter this code: user@example.com. It will take several minutes to arrive.'
329+
);
330+
});
331+
332+
it('does not double terminal punctuation when an overridden fragment already ends with it', () => {
333+
I18n.putVocabulariesForLanguage('en', {
334+
'It may take a minute to arrive': 'Arrives soon.',
335+
});
336+
337+
const result = authenticatorTextUtil.getDeliveryMessageText(emailDetails);
338+
expect(result.endsWith('Arrives soon.')).toBe(true);
339+
expect(result.endsWith('..')).toBe(false);
340+
});
341+
342+
it('does not append a terminator when an override already ends with an ellipsis (no.2)', () => {
343+
I18n.putVocabulariesForLanguage('en', {
344+
'It may take a minute to arrive': 'Might take a minute or two…',
345+
});
346+
347+
const result = authenticatorTextUtil.getDeliveryMessageText(emailDetails);
348+
expect(result.endsWith('Might take a minute or two…')).toBe(true);
349+
expect(result.endsWith('….')).toBe(false);
350+
});
351+
352+
it('does not append a terminator when an override already ends with a colon (no.2)', () => {
353+
I18n.putVocabulariesForLanguage('en', {
354+
'It may take a minute to arrive': 'Enter this code:',
355+
});
356+
357+
const result = authenticatorTextUtil.getDeliveryMessageText(emailDetails);
358+
expect(result.endsWith('Enter this code:')).toBe(true);
359+
expect(result.endsWith(':.')).toBe(false);
360+
});
361+
362+
it('keeps the ASCII period when a Latin instruction override contains a CJK proper noun (no.1)', () => {
363+
I18n.putVocabulariesForLanguage('en', {
364+
'Your code is on the way. To log in, enter the code we emailed to':
365+
'Enter the code sent by 東京 team to',
366+
});
367+
368+
const result = authenticatorTextUtil.getDeliveryMessageText(emailDetails);
369+
// Copy stays majority-Latin, so the ASCII period is kept, never `。`.
370+
expect(result).toContain('user@example.com.');
371+
expect(result).not.toContain('user@example.com。');
372+
});
373+
});

packages/ui/src/helpers/authenticator/textUtil.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import type {
77
import { translate, DefaultTexts } from '../../i18n';
88
import type { AuthenticatorRoute } from './facade';
99
import { defaultTexts } from '../../i18n/dictionaries';
10+
import {
11+
getSentenceSpacer,
12+
terminateSentence,
13+
} from '../../i18n/sentencePunctuation';
1014

1115
/**
1216
* ConfirmSignIn
@@ -35,16 +39,22 @@ const getDeliveryMessageText = (
3539
const isTextMessage = DeliveryMedium === 'SMS';
3640

3741
const arrivalMessage = translate(DefaultTexts.CODE_ARRIVAL);
42+
const spacer = getSentenceSpacer(arrivalMessage);
3843

3944
if (!(isEmailMessage || isTextMessage)) {
40-
return `${translate(DefaultTexts.CODE_SENT)}. ${arrivalMessage}.`;
45+
const sentMessage = translate(DefaultTexts.CODE_SENT);
46+
return `${terminateSentence(sentMessage)}${spacer}${terminateSentence(
47+
arrivalMessage
48+
)}`;
4149
}
4250

4351
const instructionMessage = isEmailMessage
4452
? translate(DefaultTexts.CODE_EMAILED)
4553
: translate(DefaultTexts.CODE_TEXTED);
4654

47-
return `${instructionMessage} ${Destination}. ${arrivalMessage}.`;
55+
return `${terminateSentence(
56+
`${instructionMessage} ${Destination}`
57+
)}${spacer}${terminateSentence(arrivalMessage)}`;
4858
};
4959

5060
const getDeliveryMethodText = (
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { getSentenceSpacer, terminateSentence } from '../sentencePunctuation';
2+
3+
describe('sentencePunctuation (#6966)', () => {
4+
describe('terminateSentence', () => {
5+
it('appends an ASCII period to Latin copy', () => {
6+
expect(terminateSentence('It may take a minute to arrive')).toBe(
7+
'It may take a minute to arrive.'
8+
);
9+
});
10+
11+
it('appends the ideographic full stop to majority-CJK copy (ja/zh)', () => {
12+
// Japanese
13+
expect(terminateSentence('コードを入力してください')).toBe(
14+
'コードを入力してください。'
15+
);
16+
// Chinese
17+
expect(terminateSentence('请输入验证码')).toBe('请输入验证码。');
18+
});
19+
20+
it('leaves Thai copy without a terminator', () => {
21+
const thai = 'อาจใช้เวลาสักครู่';
22+
const result = terminateSentence(thai);
23+
24+
expect(result).toBe(thai);
25+
expect(result.endsWith('.')).toBe(false);
26+
expect(result.endsWith('。')).toBe(false);
27+
});
28+
29+
it('keeps an ASCII period on a Latin sentence that contains a CJK proper noun (no.1 majority)', () => {
30+
// "東京" is a lone CJK proper noun inside otherwise-Latin copy: the old
31+
// "contains any CJK" test wrongly appended "。" here.
32+
const result = terminateSentence('Enter the code for 東京');
33+
34+
expect(result).toBe('Enter the code for 東京.');
35+
expect(result.endsWith('。')).toBe(false);
36+
});
37+
38+
it('keeps an ASCII period on a majority-Hangul Korean phrase containing a CJK codepoint (no.1 majority)', () => {
39+
// Korean copy can embed a stray CJK (Hanja) codepoint while remaining
40+
// predominantly Hangul — it must still terminate with an ASCII period.
41+
const result = terminateSentence('코드를 입력하세요 (中)');
42+
43+
expect(result).toBe('코드를 입력하세요 (中).');
44+
expect(result.endsWith('。')).toBe(false);
45+
});
46+
47+
it.each(['.', '!', '?', '。', '!', '?'])(
48+
'does not append after existing terminal punctuation %s',
49+
(punctuation) => {
50+
const sentence = `Done${punctuation}`;
51+
expect(terminateSentence(sentence)).toBe(sentence);
52+
}
53+
);
54+
55+
it.each(['…', '.', '。', ':', ':'])(
56+
'recognizes %s as terminal punctuation and appends nothing (no.2)',
57+
(punctuation) => {
58+
const sentence = `Might take a minute or two${punctuation}`;
59+
const result = terminateSentence(sentence);
60+
61+
expect(result).toBe(sentence);
62+
expect(result.endsWith('.')).toBe(false);
63+
}
64+
);
65+
66+
it('treats empty / whitespace-only input as Latin', () => {
67+
expect(terminateSentence('')).toBe('.');
68+
expect(terminateSentence(' ')).toBe(' .');
69+
});
70+
});
71+
72+
describe('getSentenceSpacer', () => {
73+
it('returns no space for majority-CJK copy', () => {
74+
expect(getSentenceSpacer('コードを入力してください')).toBe('');
75+
expect(getSentenceSpacer('请输入验证码')).toBe('');
76+
});
77+
78+
it('returns a single space for Latin, Thai and Hangul copy', () => {
79+
expect(getSentenceSpacer('It may take a minute to arrive')).toBe(' ');
80+
expect(getSentenceSpacer('อาจใช้เวลาสักครู่')).toBe(' ');
81+
expect(getSentenceSpacer('코드를 입력하세요')).toBe(' ');
82+
});
83+
84+
it('returns a single space for Latin copy containing a CJK proper noun', () => {
85+
expect(getSentenceSpacer('Enter the code for 東京')).toBe(' ');
86+
});
87+
});
88+
});

0 commit comments

Comments
 (0)