Skip to content

Commit a61a296

Browse files
committed
feat: render icu xml as fragments
1 parent f2f7078 commit a61a296

File tree

11 files changed

+2747
-1954
lines changed

11 files changed

+2747
-1954
lines changed

email/HACKING.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ fine to send and what isn't; basically the [Can I Use](https://caniuse.com/) of
4343
This also applies to the layout; always prefer React Email's `Container`, `Row` and `Column` elements for layout.
4444
They'll get turned into ugly HTML tables to do the layout - just like in the good ol' HTML days...
4545

46+
> [!TIP]
47+
> Recent versions of React Email have an embedded linter in preview mode, that checks for compatibility and other
48+
> helpful things.
49+
4650
## Layouts
4751
The core shell of emails is provided by `components/layouts/LayoutCore.tsx`. It is not expected to be used as-is, but
4852
instead to serve as a shared base for more complete layouts such as `ClassicLayout.tsx`. All emails should use a layout,
@@ -54,7 +58,7 @@ The classic layout (`ClassicLayout.tsx`) takes 3 properties:
5458
- Is it because they have an account? Is it because they enabled notifications? ...
5559
- `extra` (optional): displayed at the very bottom, useful to insert an unsubscribe link if necessary
5660

57-
These three properties are generally expected to receive output from the `t()` function documented below. The core
61+
These three properties are generally expected to receive output from the `t.raw()` function documented below. The core
5862
layout only requires the subject.
5963

6064
## Utility components
@@ -71,7 +75,10 @@ Shared parts are found in `components/parts`.
7175

7276
### `<LocalizedText />` and `t()`
7377
Most if not all text in emails are expected to be wrapped in `<LocalizedText />` (or `t()` when more appropriate).
74-
They are equivalent as `<LocalizedText />` is simply a JSX wrapper for calling `t()`.
78+
They are equivalent : `<LocalizedText />` is simply a JSX wrapper for calling `t()`.
79+
80+
There is also `t.raw()`, which works exactly like `t()` but enforces the translation to be plaintext (no HTML). It's
81+
mainly used for the subject part and the send reason.
7582

7683
The strings are written using a format similar to the familiar Tolgee ICU, via [ICU4J](https://github.com/unicode-org/icu/tree/main/icu4j)
7784
(see [MessageFormat](https://unicode-org.github.io/icu-docs/apidoc/released/icu4j/com/ibm/icu/text/MessageFormat.html)).
@@ -90,8 +97,9 @@ The `<LocalizedText />` takes the following properties:
9097

9198
The `t()` function takes the same properties, instead it takes them as arguments in the order they're described here.
9299

93-
When using the development environment, only the default value locally provided will be considered. Strings are not
94-
pulled from Tolgee to test directly within the dev environment. (At least, at this time).
100+
> [!WARNING]
101+
> When using the development environment, only the default value locally provided will be considered. Strings are not
102+
> pulled from Tolgee to test directly within the dev environment. (At least, at this time).
95103
96104
```tsx
97105
<LocalizedText

email/components/If.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,19 @@ export default function If({
3131

3232
if (process.env.NODE_ENV === 'production') {
3333
const trueCase = React.cloneElement(children[0], {
34-
'data-th-if': condition,
34+
key: 'true-case',
35+
'th:if': condition,
3536
});
3637

3738
const falseCase =
3839
children.length === 2
39-
? React.cloneElement(children[1], { 'data-th-unless': condition })
40+
? React.cloneElement(children[1], {
41+
key: 'false-case',
42+
'th:unless': condition,
43+
})
4044
: null;
4145

42-
return React.createElement(React.Fragment, {}, trueCase, falseCase);
46+
return [trueCase, falseCase];
4347
}
4448

4549
if (demoValue === false) return children[1];

email/components/LocalizedText.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import _t from './translate.js';
1818

1919
type Props = {
2020
keyName: string;
21-
defaultValue?: string;
21+
defaultValue: string;
2222
demoParams?: Record<string, any>;
2323
};
2424

email/components/layouts/ClassicLayout.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@ import If from '../If';
3131
import ImgResource from '../ImgResource';
3232
import LocalizedText from '../LocalizedText';
3333
import LayoutCore from './LayoutCore';
34+
import t, { TranslatedText } from '../translate';
3435

3536
type Props = {
3637
children: React.ReactNode;
37-
subject: React.ReactElement | string;
38-
sendReason: React.ReactElement | string;
38+
subject: TranslatedText | string;
39+
sendReason: TranslatedText | string;
3940
};
4041

4142
type SocialLinkProps = {
@@ -78,7 +79,9 @@ export default function ClassicLayout({
7879
/>
7980
</Column>
8081
<Column className="text-right">
81-
<Heading className="text-xl text-brand m-0">{subject}</Heading>
82+
<Heading className="text-xl text-brand m-0">
83+
{t.render(subject)}
84+
</Heading>
8285
</Column>
8386
</Row>
8487
</Section>
@@ -87,10 +90,10 @@ export default function ClassicLayout({
8790
</Section>
8891
<Hr className="hidden" />
8992
<Section className="p-[10px] text-xs text-gray-600 text-center">
90-
<Text className="text-xs m-0 mb-2">{sendReason}</Text>
93+
<Text className="text-xs m-0 mb-2">{t.render(sendReason)}</Text>
9194
<Container>
92-
<Column>
93-
<Row>
95+
<Row>
96+
<Column>
9497
<If condition="${isCloud}">
9598
<Container>
9699
<Row>
@@ -178,8 +181,8 @@ export default function ClassicLayout({
178181
/>
179182
</Text>
180183
</If>
181-
</Row>
182-
</Column>
184+
</Column>
185+
</Row>
183186
</Container>
184187
</Section>
185188
</Container>

email/components/layouts/LayoutCore.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,24 @@
1515
*/
1616

1717
import * as React from 'react';
18-
import { renderToString } from 'react-dom/server';
1918
import { convert } from 'html-to-text';
2019

2120
import { Head, Html, Tailwind } from '@react-email/components';
21+
import { TranslatedText } from '../translate';
2222

2323
type Props = {
2424
children: React.ReactNode;
25-
subject: React.ReactElement | string;
25+
subject: TranslatedText | string;
2626
};
2727

2828
export default function LayoutCore({ children, subject }: Props) {
29-
const subjectPlainText = convert(renderToString(subject));
30-
const thText = typeof subject === 'object' ? subject.props['th:text'] : null;
29+
const subjectPlain = typeof subject === 'object' ? subject.text : subject;
30+
const thText = typeof subject === 'object' ? subject.expr : null;
3131

3232
return (
3333
<Html>
3434
<Head>
35-
<title {...{ 'th:text': thText }}>{subjectPlainText}</title>
35+
<title {...{ 'th:text': thText }}>{convert(subjectPlain)}</title>
3636
{process.env.NODE_ENV !== 'production' && (
3737
// This is a hack to get line returns to behave as line returns.
3838
// The Kotlin renderer will handle these cases, but this is for the browser preview.

email/components/translate.ts

Lines changed: 117 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,26 @@
1515
*/
1616

1717
import * as React from 'react';
18-
import { TYPE } from '@formatjs/icu-messageformat-parser';
18+
import { ReactElement } from 'react';
19+
import { MessageFormatElement, TYPE } from '@formatjs/icu-messageformat-parser';
1920
import { IntlMessageFormat } from 'intl-messageformat';
2021

22+
declare const TranslatedTextSymbol: unique symbol;
23+
24+
export type TranslatedText = {
25+
[TranslatedTextSymbol]?: true;
26+
expr: string;
27+
text: string;
28+
};
29+
2130
const GLOBALS = {
2231
isCloud: true,
2332
instanceQualifier: 'Tolgee',
2433
instanceUrl: 'https://app.tolgee.io',
2534
};
2635

36+
const SeenIcuXmlIds = new Set<string>();
37+
2738
function formatDev(string?: string, demoParams?: Record<string, any>) {
2839
const formatted = new IntlMessageFormat(string, 'en-US').format({
2940
...GLOBALS,
@@ -39,37 +50,128 @@ function formatDev(string?: string, demoParams?: Record<string, any>) {
3950
return formatted;
4051
}
4152

42-
export default function t(
43-
key: string,
44-
defaultString?: string,
53+
function processMessageElements(
54+
id: string,
55+
elements: MessageFormatElement[],
4556
demoParams?: Record<string, any>
46-
) {
47-
const intl = new IntlMessageFormat(defaultString, 'en-US');
48-
const ast = intl.getAst();
57+
): [ReactElement[], Set<string>] {
58+
const fragments: ReactElement[] = [];
59+
const stringArguments = new Set<string>();
4960

50-
const stringArguments: string[] = [];
51-
52-
for (const node of ast) {
61+
for (const node of elements) {
5362
if (node.type === TYPE.literal || node.type === TYPE.pound) {
63+
// Text and what misc ICU syntax; not interesting
5464
continue;
5565
}
5666

5767
if (node.type === TYPE.tag) {
58-
// TODO: find a way to process the tag
68+
// Tag: needs to be converted to a Thymeleaf fragment
69+
const templateId = `${id}-${node.value}`;
70+
const [tagFrags, tagArgs] = processMessageElements(
71+
templateId,
72+
node.children
73+
);
74+
75+
tagArgs.forEach((a) => stringArguments.add(a));
76+
if (!SeenIcuXmlIds.has(templateId)) {
77+
SeenIcuXmlIds.add(templateId);
78+
fragments.push(
79+
...tagFrags,
80+
React.createElement(
81+
'th:block',
82+
{
83+
key: templateId,
84+
'th:fragment': `${templateId} (_children)`,
85+
},
86+
demoParams[node.value](
87+
React.createElement('div', { 'th:replace': '${_children}' })
88+
)
89+
)
90+
);
91+
}
92+
5993
continue;
6094
}
6195

62-
stringArguments.push(`${node.value}: ${node.value.replace(/__/g, '.')}`);
96+
// Everything else is some form of variable: keep track of them
97+
stringArguments.add(node.value);
6398
}
6499

100+
return [fragments, stringArguments];
101+
}
102+
103+
function renderTranslatedText(
104+
key: string,
105+
defaultString: string,
106+
demoParams?: Record<string, any>
107+
) {
108+
const id = `intl-${key.replace(/\./g, '__')}`;
109+
const intl = new IntlMessageFormat(defaultString, 'en-US');
110+
const ast = intl.getAst();
111+
112+
const [fragments, stringArguments] = processMessageElements(
113+
id,
114+
ast,
115+
demoParams
116+
);
117+
65118
const text =
66119
process.env.NODE_ENV === 'production'
67120
? defaultString
68121
: formatDev(defaultString, demoParams);
69122

70-
const messageExpression = stringArguments.length
71-
? `#{${key}(\${ { ${stringArguments.join(', ')} } })}`
123+
const stringArgs = Array.from(stringArguments);
124+
const stringArgsMap = stringArgs.map((a) => `${a}: ${a.replace(/__/g, '.')}`);
125+
126+
const messageExpression = stringArgsMap.length
127+
? `#{${key}(\${ { ${stringArgsMap.join(', ')} } })}`
72128
: `#{${key}}`;
73129

74-
return React.createElement('span', { 'th:utext': messageExpression }, text);
130+
return { fragments, text, messageExpression };
131+
}
132+
133+
export default function t(
134+
key: string,
135+
defaultString: string,
136+
demoParams?: Record<string, unknown>
137+
) {
138+
const { fragments, text, messageExpression } = renderTranslatedText(
139+
key,
140+
defaultString,
141+
demoParams
142+
);
143+
144+
return [
145+
...fragments,
146+
React.createElement(
147+
'span',
148+
{ key: 'render-el', 'th:utext': messageExpression },
149+
text
150+
),
151+
];
75152
}
153+
154+
t.raw = function (
155+
key: string,
156+
defaultString: string,
157+
demoParams?: Record<string, any>
158+
): TranslatedText {
159+
const { fragments, text, messageExpression } = renderTranslatedText(
160+
key,
161+
defaultString,
162+
demoParams
163+
);
164+
165+
if (fragments.length)
166+
throw new Error('Invalid raw translation: cannot contain components.');
167+
168+
return {
169+
expr: messageExpression,
170+
text,
171+
};
172+
};
173+
174+
t.render = function (text: TranslatedText | string) {
175+
if (typeof text === 'string') return text;
176+
return React.createElement('span', { 'th:utext': text.expr }, text.text);
177+
};

email/emails/registration-confirm.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ import LocalizedText from '../components/LocalizedText';
2525
export default function RegistrationConfirmEmail() {
2626
return (
2727
<ClassicLayout
28-
subject={t('registration-confirm-subject', 'Confirm your account')}
29-
sendReason={t(
28+
subject={t.raw('registration-confirm-subject', 'Confirm your account')}
29+
sendReason={t.raw(
3030
'send-reason-created-account',
3131
"You're receiving this email because you've created an account on {instanceQualifier}",
3232
{ instanceQualifier: 'Tolgee' }

email/env.d.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Copyright (C) 2025 Tolgee s.r.o. and contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import 'react';
18+
19+
declare module 'react' {
20+
interface Attributes {
21+
[k: `th:${string}`]: unknown;
22+
}
23+
}

0 commit comments

Comments
 (0)