Skip to content

Commit dbc9ed2

Browse files
feat(interpolation): add pluralization and select using @messageformat/core
1 parent cfd05ea commit dbc9ed2

File tree

8 files changed

+250
-66
lines changed

8 files changed

+250
-66
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"@angular/platform-browser": "~18.2.0",
3131
"@angular/platform-browser-dynamic": "~18.2.0",
3232
"@angular/router": "~18.2.0",
33+
"@messageformat/core": "^3.3.0",
3334
"rxjs": "~7.8.0",
3435
"tslib": "^2.3.0",
3536
"zone.js": "~0.14.3"

packages/core/.eslintrc.json

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,4 @@
11
{
2-
"extends": ["../.eslintrc.json"],
3-
"ignorePatterns": ["!**/*"],
4-
"overrides": [
5-
{
6-
"files": ["*.ts"],
7-
"extends": ["plugin:@nx/angular", "plugin:@angular-eslint/template/process-inline-templates"],
8-
"rules": {
9-
"@angular-eslint/directive-selector": [
10-
"error",
11-
{
12-
"type": "attribute",
13-
"prefix": "lib",
14-
"style": "camelCase"
15-
}
16-
],
17-
"@angular-eslint/component-selector": [
18-
"error",
19-
{
20-
"type": "element",
21-
"prefix": "lib",
22-
"style": "kebab-case"
23-
}
24-
]
25-
}
26-
},
27-
{
28-
"files": ["*.html"],
29-
"extends": ["plugin:@nx/angular-template"],
30-
"rules": {}
31-
},
32-
{
33-
"files": ["*.json"],
34-
"parser": "jsonc-eslint-parser",
35-
"rules": {
36-
"@nx/dependency-checks": "error"
37-
}
38-
}
39-
]
2+
"extends": ["../../.eslintrc.json"],
3+
"ignorePatterns": ["!**/*"]
404
}

packages/core/jest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable */
22
export default {
33
displayName: 'ng-intl',
4-
preset: '../jest.preset.js',
4+
preset: '../../jest.preset.js',
55
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
66
coverageDirectory: '../coverage/ng-intl',
77
transform: {
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { InterpolationPipe } from './interpolation.pipe';
3+
import { provideTranslation } from './translation.provider';
4+
5+
describe('InterpolationPipe', () => {
6+
let pipe: InterpolationPipe;
7+
8+
beforeEach(() => {
9+
TestBed.configureTestingModule({
10+
providers: [InterpolationPipe, provideTranslation()],
11+
});
12+
pipe = TestBed.inject(InterpolationPipe);
13+
});
14+
15+
it('should create an instance', () => {
16+
expect(pipe).toBeTruthy();
17+
});
18+
19+
describe('Simple Interpolation', () => {
20+
it('should handle basic variable replacement', () => {
21+
expect(pipe.transform('Hello, {{name}}!', { name: 'World' })).toBe('Hello, World!');
22+
});
23+
24+
it('should handle multiple variables', () => {
25+
expect(pipe.transform('{{greeting}}, {{name}}!', { greeting: 'Hello', name: 'World' })).toBe('Hello, World!');
26+
});
27+
28+
it('should ignore spaces in variable names', () => {
29+
expect(pipe.transform('Hello, {{ name }}!', { name: 'World' })).toBe('Hello, World!');
30+
});
31+
32+
it('should leave unmatched variables unchanged', () => {
33+
expect(pipe.transform('Hello, {{name}}! {{missing}}', { name: 'World' })).toBe('Hello, World! {{missing}}');
34+
});
35+
36+
it('should handle number variables', () => {
37+
expect(pipe.transform('You have {{count}} messages.', { count: 5 })).toBe('You have 5 messages.');
38+
});
39+
40+
it('should handle boolean variables', () => {
41+
expect(pipe.transform('Notifications are {{enabled}}.', { enabled: true })).toBe('Notifications are true.');
42+
});
43+
});
44+
45+
describe('Select Rules', () => {
46+
it('should handle basic select rule', () => {
47+
const input = 'mySelectRule: "{gender, select, male {He} female {She} other {They}}"';
48+
expect(pipe.transform(input, { gender: 'male' })).toBe('mySelectRule: "He"');
49+
expect(pipe.transform(input, { gender: 'female' })).toBe('mySelectRule: "She"');
50+
expect(pipe.transform(input, { gender: 'other' })).toBe('mySelectRule: "They"');
51+
});
52+
53+
it('should use "other" as fallback in select rule', () => {
54+
const input = 'mySelectRule: "{gender, select, male {He} female {She} other {They}}"';
55+
expect(pipe.transform(input, { gender: 'unknown' })).toBe('mySelectRule: "They"');
56+
});
57+
58+
it('should handle select rule with complex values', () => {
59+
const input = 'mySelectRule: "{user, select, admin {Full access} moderator {Limited access} other {No access}}"';
60+
expect(pipe.transform(input, { user: 'admin' })).toBe('mySelectRule: "Full access"');
61+
expect(pipe.transform(input, { user: 'moderator' })).toBe('mySelectRule: "Limited access"');
62+
expect(pipe.transform(input, { user: 'guest' })).toBe('mySelectRule: "No access"');
63+
});
64+
65+
it('should handle multiple select rules', () => {
66+
const input =
67+
'gender: "{gender, select, male {He} female {She} other {They}}" pronoun: "{gender, select, male {his} female {her} other {their}}"';
68+
expect(pipe.transform(input, { gender: 'male' })).toBe('gender: "He" pronoun: "his"');
69+
expect(pipe.transform(input, { gender: 'female' })).toBe('gender: "She" pronoun: "her"');
70+
expect(pipe.transform(input, { gender: 'other' })).toBe('gender: "They" pronoun: "their"');
71+
});
72+
});
73+
74+
describe('Plural Rules', () => {
75+
it('should handle basic plural rule', () => {
76+
const input = 'myPluralRule: "{count, plural, =0 {no results} one {1 result} other {# results}}"';
77+
expect(pipe.transform(input, { count: 0 })).toBe('myPluralRule: "no results"');
78+
expect(pipe.transform(input, { count: 1 })).toBe('myPluralRule: "1 result"');
79+
expect(pipe.transform(input, { count: 2 })).toBe('myPluralRule: "2 results"');
80+
});
81+
82+
it('should handle exact matches in plural rules', () => {
83+
const input = 'myPluralRule: "{count, plural, =0 {no results} =1 {exactly one result} other {# results}}"';
84+
expect(pipe.transform(input, { count: 0 })).toBe('myPluralRule: "no results"');
85+
expect(pipe.transform(input, { count: 1 })).toBe('myPluralRule: "exactly one result"');
86+
expect(pipe.transform(input, { count: 2 })).toBe('myPluralRule: "2 results"');
87+
});
88+
89+
it('should replace # with the actual count', () => {
90+
const input = 'myPluralRule: "{count, plural, =0 {no results} one {# result} other {# results}}"';
91+
expect(pipe.transform(input, { count: 5 })).toBe('myPluralRule: "5 results"');
92+
});
93+
94+
it('should handle multiple plural rules', () => {
95+
const input =
96+
'apples: "{apples, plural, =0 {no apples} one {1 apple} other {# apples}}" oranges: "{oranges, plural, =0 {no oranges} one {1 orange} other {# oranges}}"';
97+
expect(pipe.transform(input, { apples: 0, oranges: 1 })).toBe('apples: "no apples" oranges: "1 orange"');
98+
expect(pipe.transform(input, { apples: 1, oranges: 2 })).toBe('apples: "1 apple" oranges: "2 oranges"');
99+
});
100+
});
101+
102+
describe('Complex Scenarios', () => {
103+
it('should handle nested interpolation, select, and plural rules', () => {
104+
const input =
105+
'{{user}} has myPluralRule: "{count, plural, =0 {no {{item}}s} one {1 {{item}}} other {# {{item}}s}}" and mySelectRule: "{gender, select, male {he likes} female {she likes} other {they like}} it."';
106+
const result = pipe.transform(input, { user: 'Alice', count: 2, item: 'apple', gender: 'female' });
107+
expect(result).toBe('Alice has myPluralRule: "2 apples" and mySelectRule: "she likes it."');
108+
});
109+
110+
it('should handle multiple nested rules', () => {
111+
const input =
112+
'gender: "{gender, select, male {He has} female {She has} other {They have}}" count: "{count, plural, =0 {no items} one {one item} other {# items}}"';
113+
expect(pipe.transform(input, { gender: 'male', count: 0 })).toBe('gender: "He has" count: "no items"');
114+
expect(pipe.transform(input, { gender: 'female', count: 1 })).toBe('gender: "She has" count: "one item"');
115+
expect(pipe.transform(input, { gender: 'other', count: 5 })).toBe('gender: "They have" count: "5 items"');
116+
});
117+
});
118+
119+
describe('Edge Cases', () => {
120+
it('should return an empty string for null input', () => {
121+
expect(pipe.transform(null, {})).toBe('');
122+
});
123+
124+
it('should return an empty string for undefined input', () => {
125+
expect(pipe.transform(undefined, {})).toBe('');
126+
});
127+
128+
it('should handle missing arguments', () => {
129+
const input = '{{var1}} {{var2}} mySelectRule: "{var3, select, a {A} b {B} other {C}}"';
130+
expect(pipe.transform(input, { var1: 'Hello' })).toBe('Hello {{var2}} mySelectRule: "C"');
131+
});
132+
133+
it('should handle empty select and plural rules', () => {
134+
const input = 'select: "{var, select,}" plural: "{count, plural,}"';
135+
expect(pipe.transform(input, { var: 'test', count: 5 })).toBe('select: "" plural: ""');
136+
});
137+
});
138+
});
Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,68 @@
11
import { Inject, Pipe, PipeTransform } from '@angular/core';
2-
import { InterpolationConfig, TranslationConfig, TranslationConfigFormat } from './translation.config';
2+
import { TranslationConfig, TranslationConfigFormat } from './translation.config';
3+
import MessageFormatter from '@messageformat/core';
34

45
@Pipe({
5-
standalone: true,
66
name: 'interpolate',
7-
pure: true,
7+
standalone: true,
88
})
99
export class InterpolationPipe implements PipeTransform {
10-
private readonly openSeparator: string;
11-
private readonly closeSeparator: string;
10+
private formatter: MessageFormatter;
1211

13-
constructor(@Inject(TranslationConfig) private config: TranslationConfigFormat) {
14-
this.openSeparator = InterpolationConfig[config.interpolation].start.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
15-
this.closeSeparator = InterpolationConfig[config.interpolation].end.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
12+
constructor(@Inject(TranslationConfig) config: TranslationConfigFormat) {
13+
// TODO: make this reactive to lang changes
14+
this.formatter = new MessageFormatter(config.defaultLanguage);
1615
}
1716

18-
transform(value: string | undefined | null, args: Record<string, string>): string {
19-
if (!value) {
17+
transform(value: string | null | undefined, properties: Record<string, string | number | boolean>): string {
18+
if (value == null) {
2019
return '';
2120
}
22-
const regex = new RegExp(`${this.openSeparator}\\s*(.*?)\\s*${this.closeSeparator}`, 'g');
2321

24-
return value.replace(regex, (match, key) => {
22+
try {
23+
// First, handle simple interpolation with double brackets
24+
const simpleInterpolated = this.simpleInterpolate(value, properties);
25+
26+
// Then, handle empty select and plural rules
27+
const emptyRulesHandled = this.handleEmptyRules(simpleInterpolated);
28+
29+
// Finally, use a custom compile function for complex operations
30+
return this.customCompile(emptyRulesHandled, properties);
31+
} catch (error) {
32+
console.error('Error interpolating message:', error);
33+
return value;
34+
}
35+
}
36+
37+
private simpleInterpolate(value: string, properties: Record<string, string | number | boolean>): string {
38+
return value.replace(/\{\{(\s*[\w.]+\s*)}}/g, (match, key) => {
2539
const trimmedKey = key.trim();
26-
return trimmedKey in args ? args[trimmedKey] : match;
40+
return Object.prototype.hasOwnProperty.call(properties, trimmedKey) ? String(properties[trimmedKey]) : match;
2741
});
2842
}
43+
44+
private handleEmptyRules(value: string): string {
45+
// Handle empty select rules
46+
value = value.replace(/\{(\w+),\s*select\s*,\s*}/g, '');
47+
48+
// Handle empty plural rules
49+
value = value.replace(/\{(\w+),\s*plural\s*,\s*}/g, '');
50+
51+
return value;
52+
}
53+
54+
private customCompile(message: string, properties: Record<string, string | number | boolean>): string {
55+
const compiledFunc = this.formatter.compile(message);
56+
const proxyHandler = {
57+
get: (target: never, prop: string) => {
58+
if (prop in target) {
59+
return target[prop];
60+
}
61+
// Return a string representation of the unmatched key
62+
return `{${prop}}`;
63+
},
64+
};
65+
const proxyProperties = new Proxy(properties, proxyHandler);
66+
return compiledFunc(proxyProperties);
67+
}
2968
}
Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,12 @@
11
import { InjectionToken } from '@angular/core';
22
import { AvailableLanguages } from './languages';
33

4-
export const InterpolationConfig = {
5-
brackets: {
6-
start: '{{',
7-
end: '}}',
8-
},
9-
parantheses: {
10-
start: '((',
11-
end: '))',
12-
},
13-
} as const;
14-
154
export type TranslationConfigFormat = {
165
defaultLanguage: AvailableLanguages;
17-
interpolation: keyof typeof InterpolationConfig;
186
};
197

208
export const defaultConfigs: TranslationConfigFormat = {
219
defaultLanguage: 'en',
22-
interpolation: 'brackets',
2310
};
2411

2512
export const TranslationConfig = new InjectionToken<TranslationConfigFormat>('TranslationConfig');

packages/core/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"noImplicitOverride": true,
77
"noPropertyAccessFromIndexSignature": true,
88
"noImplicitReturns": true,
9-
"noFallthroughCasesInSwitch": true
9+
"noFallthroughCasesInSwitch": true,
10+
"esModuleInterop": true
1011
},
1112
"files": [],
1213
"include": [],

0 commit comments

Comments
 (0)