Skip to content

Commit d91b6d7

Browse files
committed
fix: ICU escapes
1 parent 896f0a1 commit d91b6d7

7 files changed

Lines changed: 114 additions & 5 deletions

File tree

cmp/compiler/src/plugin/transform/process-file.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
constructServerImport,
1111
constructTranslationCall,
1212
createTranslationEntry,
13+
escapeTextForICU,
1314
hasUseI18nDirective,
1415
inferComponentName,
1516
injectServerHook,
@@ -293,18 +294,18 @@ function serializeJSXChildren(
293294
normalized.length > 0
294295
) {
295296
if (!text.endsWith(" ")) {
296-
text += " ";
297+
normalized = ` ${normalized}`;
297298
}
298299
}
299300

300-
text += normalized;
301-
302301
// If this node ends with whitespace and has content, add trailing space
303302
if (normalized.length > 0 && child.value.match(/\s$/)) {
304303
if (!text.endsWith(" ")) {
305-
text += " ";
304+
normalized = `${normalized} `;
306305
}
307306
}
307+
308+
text += escapeTextForICU(normalized);
308309
} else if (child.type === "JSXExpressionContainer") {
309310
// Extract variable name from expression
310311
const expr = child.expression;
@@ -436,6 +437,7 @@ function transformJSXText(
436437
path: NodePath<t.JSXText>,
437438
state: VisitorsInternalState,
438439
): void {
440+
// These messages are rendered as is, so no ICU escaping is required.
439441
const text = normalizeWhitespace(path.node.value);
440442
if (text.length == 0) return;
441443

cmp/compiler/src/plugin/transform/transform.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2372,5 +2372,35 @@ export const Button = function() { return <button>Click me</button>; }
23722372

23732373
expect(result.code).toMatchSnapshot();
23742374
});
2375+
2376+
it.only("should escape literal angle brackets in text", () => {
2377+
const code = `
2378+
export default function Help() {
2379+
return (
2380+
<div>
2381+
<p>To wrap text, write &lt;&gt;content&lt;/&gt;</p>
2382+
<p>Or use &lt;Fragment&gt;content&lt;/Fragment&gt;</p>
2383+
</div>
2384+
);
2385+
}
2386+
`;
2387+
2388+
const result = transformComponent({
2389+
code,
2390+
filePath: "src/Help.tsx",
2391+
config,
2392+
});
2393+
2394+
expect(result.newEntries).toHaveLength(2);
2395+
const firstEntry = asContent(result.newEntries![0]);
2396+
expect(firstEntry.sourceText).toBe(
2397+
"To wrap text, write '<'>content'<'/>",
2398+
);
2399+
2400+
const secondEntry = asContent(result.newEntries![1]);
2401+
expect(secondEntry.sourceText).toBe(
2402+
"Or use '<'Fragment>content'<'/Fragment>",
2403+
);
2404+
});
23752405
});
23762406
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Tests for ICU MessageFormat escaping utility
3+
*/
4+
import { describe, expect, it } from "vitest";
5+
import IntlMessageFormat from "intl-messageformat";
6+
import { escapeTextForICU } from "./utils";
7+
8+
describe("escapeTextForICU", () => {
9+
it.each([
10+
// [input, expectedEscaped, expectedRendered]
11+
["It's a test", "It''s a test", "It's a test"],
12+
[
13+
"Use {braces} in code",
14+
"Use '{'braces'}' in code",
15+
"Use {braces} in code",
16+
],
17+
[
18+
"Use <>content</> for fragments",
19+
"Use '<'>content'<'/> for fragments",
20+
"Use <>content</> for fragments",
21+
],
22+
["Use #hashtags", "Use '#'hashtags", "Use '#'hashtags"], // ICU doesn't unescape #
23+
[
24+
"It's {test} with <tags> and #hash",
25+
"It''s '{'test'}' with '<'tags> and '#'hash",
26+
"It's {test} with <tags> and '#'hash",
27+
],
28+
[
29+
"Use <Fragment>content</Fragment>",
30+
"Use '<'Fragment>content'<'/Fragment>",
31+
"Use <Fragment>content</Fragment>",
32+
],
33+
["Plain text", "Plain text", "Plain text"],
34+
[
35+
"Write <>text</> or <Fragment>text</Fragment>",
36+
"Write '<'>text'<'/> or '<'Fragment>text'<'/Fragment>",
37+
"Write <>text</> or <Fragment>text</Fragment>",
38+
],
39+
["<>", "'<'>", "<>"],
40+
["</>", "'<'/>", "</>"],
41+
])(
42+
"should escape %s correctly",
43+
(input, expectedEscaped, expectedRendered) => {
44+
const escaped = escapeTextForICU(input);
45+
expect(escaped).toBe(expectedEscaped);
46+
47+
const formatter = new IntlMessageFormat(escaped, "en");
48+
const result = formatter.format();
49+
expect(result).toBe(expectedRendered);
50+
},
51+
);
52+
});

cmp/compiler/src/plugin/transform/utils.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@ export function normalizeWhitespace(text: string): string {
2525
.trim(); // Remove leading/trailing whitespace
2626
}
2727

28+
/**
29+
* Escape literal angle brackets that are not part of ICU MessageFormat tags
30+
*/
31+
export function escapeTextForICU(text: string): string {
32+
// Related spec - https://unicode-org.github.io/icu/userguide/format_parse/messages/#quotingescaping
33+
return text
34+
.replace(/'/g, "''")
35+
.replace(/\{/g, "'{'")
36+
.replace(/}/g, "'}'")
37+
.replace(/</g, "'<'")
38+
.replace(/#/g, "'#'");
39+
}
40+
2841
/**
2942
* Detect if a function is a React component by checking if it returns JSX
3043
*/

cmp/compiler/src/plugin/vite.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export function lingoCompilerPlugin(options: LingoPluginOptions) {
2525

2626
// Attach Lingo config for CLI extraction
2727
// @ts-expect-error - Internal property for CLI access
28-
plugin._lingoConfig = fullOptions;
28+
plugin._lingoConfig = options;
2929

3030
return plugin;
3131
}

cmp/compiler/src/react/shared/__snapshots__/render-rich-text.test.tsx.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ exports[`renderRichText > should handle nested components inside component tags
2222
]
2323
`;
2424

25+
exports[`renderRichText > should handle text with literal angle brackets 1`] = `"To translate it you have to wrap it into the <'>some text</>"`;
26+
2527
exports[`renderRichText > should handle the page.tsx example case 1`] = `
2628
[
2729
<React.Fragment

cmp/compiler/src/react/shared/render-rich-text.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,14 @@ describe("renderRichText", () => {
160160
);
161161
expect(result).toMatchSnapshot();
162162
});
163+
164+
it("should handle text with literal angle brackets", () => {
165+
const result = renderRichText(
166+
"To wrap text use: '<'>content'<'/> or '<'Fragment>content'<'/Fragment>",
167+
{},
168+
);
169+
expect(result).toBe(
170+
"To wrap text use: <>content</> or <Fragment>content</Fragment>",
171+
);
172+
});
163173
});

0 commit comments

Comments
 (0)