Skip to content

Commit 8683a93

Browse files
karaRobert Messerle
authored andcommitted
feat(i18n): add custom placeholder names
Closes angular#7799
1 parent 399fa27 commit 8683a93

File tree

7 files changed

+189
-18
lines changed

7 files changed

+189
-18
lines changed

modules/angular2/src/core/change_detection/parser/lexer.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ export const $BACKSLASH = 92;
145145
export const $RBRACKET = 93;
146146
const $CARET = 94;
147147
const $_ = 95;
148-
148+
export const $BT = 96;
149149
const $a = 97, $e = 101, $f = 102, $n = 110, $r = 114, $t = 116, $u = 117, $v = 118, $z = 122;
150150

151151
export const $LBRACE = 123;
@@ -415,6 +415,10 @@ function isExponentSign(code: number): boolean {
415415
return code == $MINUS || code == $PLUS;
416416
}
417417

418+
export function isQuote(code: number): boolean {
419+
return code === $SQ || code === $DQ || code === $BT;
420+
}
421+
418422
function unescape(code: number): number {
419423
switch (code) {
420424
case $n:

modules/angular2/src/core/change_detection/parser/parser.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
Lexer,
77
EOF,
88
isIdentifier,
9+
isQuote,
910
Token,
1011
$PERIOD,
1112
$COLON,
@@ -16,7 +17,8 @@ import {
1617
$LBRACE,
1718
$RBRACE,
1819
$LPAREN,
19-
$RPAREN
20+
$RPAREN,
21+
$SLASH
2022
} from './lexer';
2123
import {reflector, Reflector} from 'angular2/src/core/reflection/reflection';
2224
import {
@@ -73,7 +75,7 @@ export class Parser {
7375

7476
parseAction(input: string, location: any): ASTWithSource {
7577
this._checkNoInterpolation(input, location);
76-
var tokens = this._lexer.tokenize(input);
78+
var tokens = this._lexer.tokenize(this._stripComments(input));
7779
var ast = new _ParseAST(input, location, tokens, this._reflector, true).parseChain();
7880
return new ASTWithSource(ast, input, location);
7981
}
@@ -102,7 +104,7 @@ export class Parser {
102104
}
103105

104106
this._checkNoInterpolation(input, location);
105-
var tokens = this._lexer.tokenize(input);
107+
var tokens = this._lexer.tokenize(this._stripComments(input));
106108
return new _ParseAST(input, location, tokens, this._reflector, false).parseChain();
107109
}
108110

@@ -128,7 +130,7 @@ export class Parser {
128130
let expressions = [];
129131

130132
for (let i = 0; i < split.expressions.length; ++i) {
131-
var tokens = this._lexer.tokenize(split.expressions[i]);
133+
var tokens = this._lexer.tokenize(this._stripComments(split.expressions[i]));
132134
var ast = new _ParseAST(input, location, tokens, this._reflector, false).parseChain();
133135
expressions.push(ast);
134136
}
@@ -164,6 +166,28 @@ export class Parser {
164166
return new ASTWithSource(new LiteralPrimitive(input), input, location);
165167
}
166168

169+
private _stripComments(input: string): string {
170+
let i = this._commentStart(input);
171+
return isPresent(i) ? input.substring(0, i).trim() : input;
172+
}
173+
174+
private _commentStart(input: string): number {
175+
var outerQuote = null;
176+
for (var i = 0; i < input.length - 1; i++) {
177+
let char = StringWrapper.charCodeAt(input, i);
178+
let nextChar = StringWrapper.charCodeAt(input, i + 1);
179+
180+
if (char === $SLASH && nextChar == $SLASH && isBlank(outerQuote)) return i;
181+
182+
if (outerQuote === char) {
183+
outerQuote = null;
184+
} else if (isBlank(outerQuote) && isQuote(char)) {
185+
outerQuote = char;
186+
}
187+
}
188+
return null;
189+
}
190+
167191
private _checkNoInterpolation(input: string, location: any): void {
168192
var parts = StringWrapper.split(input, INTERPOLATION_REGEXP);
169193
if (parts.length > 1) {

modules/angular2/src/i18n/i18n_html_parser.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,16 @@ import {
2525
partition,
2626
Part,
2727
stringifyNodes,
28-
meaning
28+
meaning,
29+
getPhNameFromBinding,
30+
dedupePhName
2931
} from './shared';
3032

3133
const _I18N_ATTR = "i18n";
3234
const _PLACEHOLDER_ELEMENT = "ph";
3335
const _NAME_ATTR = "name";
3436
const _I18N_ATTR_PREFIX = "i18n-";
35-
let _PLACEHOLDER_EXPANDED_REGEXP = RegExpWrapper.create(`\\<ph(\\s)+name=("(\\d)+")\\>\\<\\/ph\\>`);
37+
let _PLACEHOLDER_EXPANDED_REGEXP = RegExpWrapper.create(`\\<ph(\\s)+name=("(\\w)+")\\>\\<\\/ph\\>`);
3638

3739
/**
3840
* Creates an i18n-ed version of the parsed template.
@@ -322,19 +324,31 @@ export class I18nHtmlParser implements HtmlParser {
322324

323325
private _replacePlaceholdersWithExpressions(message: string, exps: string[],
324326
sourceSpan: ParseSourceSpan): string {
327+
let expMap = this._buildExprMap(exps);
325328
return RegExpWrapper.replaceAll(_PLACEHOLDER_EXPANDED_REGEXP, message, (match) => {
326329
let nameWithQuotes = match[2];
327330
let name = nameWithQuotes.substring(1, nameWithQuotes.length - 1);
328-
let index = NumberWrapper.parseInt(name, 10);
329-
return this._convertIntoExpression(index, exps, sourceSpan);
331+
return this._convertIntoExpression(name, expMap, sourceSpan);
330332
});
331333
}
332334

333-
private _convertIntoExpression(index: number, exps: string[], sourceSpan: ParseSourceSpan) {
334-
if (index >= 0 && index < exps.length) {
335-
return `{{${exps[index]}}}`;
335+
private _buildExprMap(exps: string[]): Map<string, string> {
336+
let expMap = new Map<string, string>();
337+
let usedNames = new Map<string, number>();
338+
339+
for (var i = 0; i < exps.length; i++) {
340+
let phName = getPhNameFromBinding(exps[i], i);
341+
expMap.set(dedupePhName(usedNames, phName), exps[i]);
342+
}
343+
return expMap;
344+
}
345+
346+
private _convertIntoExpression(name: string, expMap: Map<string, string>,
347+
sourceSpan: ParseSourceSpan) {
348+
if (expMap.has(name)) {
349+
return `{{${expMap.get(name)}}}`;
336350
} else {
337-
throw new I18nError(sourceSpan, `Invalid interpolation index '${index}'`);
351+
throw new I18nError(sourceSpan, `Invalid interpolation name '${name}'`);
338352
}
339353
}
340354
}
@@ -360,4 +374,4 @@ class _CreateNodeMapping implements HtmlAstVisitor {
360374
visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { return null; }
361375

362376
visitComment(ast: HtmlCommentAst, context: any): any { return ""; }
363-
}
377+
}

modules/angular2/src/i18n/shared.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ import {
1010
HtmlExpansionCaseAst,
1111
htmlVisitAll
1212
} from 'angular2/src/compiler/html_ast';
13-
import {isPresent, isBlank} from 'angular2/src/facade/lang';
13+
import {isPresent, isBlank, StringWrapper} from 'angular2/src/facade/lang';
1414
import {Message} from './message';
1515
import {Parser} from 'angular2/src/core/change_detection/parser/parser';
1616

1717
export const I18N_ATTR = "i18n";
1818
export const I18N_ATTR_PREFIX = "i18n-";
19+
var CUSTOM_PH_EXP = /\/\/[\s\S]*i18n[\s\S]*\([\s\S]*ph[\s\S]*=[\s\S]*"([\s\S]*?)"[\s\S]*\)/g;
1920

2021
/**
2122
* An i18n error.
@@ -115,12 +116,15 @@ export function removeInterpolation(value: string, source: ParseSourceSpan,
115116
parser: Parser): string {
116117
try {
117118
let parsed = parser.splitInterpolation(value, source.toString());
119+
let usedNames = new Map<string, number>();
118120
if (isPresent(parsed)) {
119121
let res = "";
120122
for (let i = 0; i < parsed.strings.length; ++i) {
121123
res += parsed.strings[i];
122124
if (i != parsed.strings.length - 1) {
123-
res += `<ph name="${i}"/>`;
125+
let customPhName = getPhNameFromBinding(parsed.expressions[i], i);
126+
customPhName = dedupePhName(usedNames, customPhName);
127+
res += `<ph name="${customPhName}"/>`;
124128
}
125129
}
126130
return res;
@@ -132,6 +136,22 @@ export function removeInterpolation(value: string, source: ParseSourceSpan,
132136
}
133137
}
134138

139+
export function getPhNameFromBinding(input: string, index: number): string {
140+
let customPhMatch = StringWrapper.split(input, CUSTOM_PH_EXP);
141+
return customPhMatch.length > 1 ? customPhMatch[1] : `${index}`;
142+
}
143+
144+
export function dedupePhName(usedNames: Map<string, number>, name: string): string {
145+
let duplicateNameCount = usedNames.get(name);
146+
if (isPresent(duplicateNameCount)) {
147+
usedNames.set(name, duplicateNameCount + 1);
148+
return `${name}_${duplicateNameCount}`;
149+
} else {
150+
usedNames.set(name, 1);
151+
return name;
152+
}
153+
}
154+
135155
export function stringifyNodes(nodes: HtmlAst[], parser: Parser): string {
136156
let visitor = new _StringifyVisitor(parser);
137157
return htmlVisitAll(visitor, nodes).join("");

modules/angular2/test/core/change_detection/parser/parser_spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ export function main() {
104104

105105
it('should parse grouped expressions', () => { checkAction("(1 + 2) * 3", "1 + 2 * 3"); });
106106

107+
it('should ignore comments in expressions', () => { checkAction('a //comment', 'a'); });
108+
109+
it('should retain // in string literals',
110+
() => { checkAction(`"http://www.google.com"`, `"http://www.google.com"`); });
111+
107112
it('should parse an empty string', () => { checkAction(''); });
108113

109114
describe("literals", () => {
@@ -270,6 +275,14 @@ export function main() {
270275
});
271276

272277
it('should parse conditional expression', () => { checkBinding('a < b ? a : b'); });
278+
279+
it('should ignore comments in bindings', () => { checkBinding('a //comment', 'a'); });
280+
281+
it('should retain // in string literals',
282+
() => { checkBinding(`"http://www.google.com"`, `"http://www.google.com"`); });
283+
284+
it('should retain // in : microsyntax', () => { checkBinding('one:a//b', 'one:a//b'); });
285+
273286
});
274287

275288
describe('parseTemplateBindings', () => {
@@ -425,6 +438,31 @@ export function main() {
425438
it('should parse expression with newline characters', () => {
426439
checkInterpolation(`{{ 'foo' +\n 'bar' +\r 'baz' }}`, `{{ "foo" + "bar" + "baz" }}`);
427440
});
441+
442+
describe("comments", () => {
443+
it('should ignore comments in interpolation expressions',
444+
() => { checkInterpolation('{{a //comment}}', '{{ a }}'); });
445+
446+
it('should retain // in single quote strings', () => {
447+
checkInterpolation(`{{ 'http://www.google.com' }}`, `{{ "http://www.google.com" }}`);
448+
});
449+
450+
it('should retain // in double quote strings', () => {
451+
checkInterpolation(`{{ "http://www.google.com" }}`, `{{ "http://www.google.com" }}`);
452+
});
453+
454+
it('should ignore comments after string literals',
455+
() => { checkInterpolation(`{{ "a//b" //comment }}`, `{{ "a//b" }}`); });
456+
457+
it('should retain // in complex strings', () => {
458+
checkInterpolation(`{{"//a\'//b\`//c\`//d\'//e" //comment}}`, `{{ "//a\'//b\`//c\`//d\'//e" }}`);
459+
});
460+
461+
it('should retain // in nested, unterminated strings', () => {
462+
checkInterpolation(`{{ "a\'b\`" //comment}}`, `{{ "a\'b\`" }}`);
463+
});
464+
});
465+
428466
});
429467

430468
describe("parseSimpleBinding", () => {

modules/angular2/test/i18n/i18n_html_parser_spec.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,36 @@ export function main() {
7676
.toEqual([[HtmlElementAst, 'div', 0], [HtmlAttrAst, 'value', '{{b}} or {{a}}']]);
7777
});
7878

79+
it('should handle interpolation with custom placeholder names', () => {
80+
let translations: {[key: string]: string} = {};
81+
translations[id(new Message('<ph name="FIRST"/> and <ph name="SECOND"/>', null, null))] =
82+
'<ph name="SECOND"/> or <ph name="FIRST"/>';
83+
84+
expect(
85+
humanizeDom(parse(
86+
`<div value='{{a //i18n(ph="FIRST")}} and {{b //i18n(ph="SECOND")}}' i18n-value></div>`,
87+
translations)))
88+
.toEqual([
89+
[HtmlElementAst, 'div', 0],
90+
[HtmlAttrAst, 'value', '{{b //i18n(ph="SECOND")}} or {{a //i18n(ph="FIRST")}}']
91+
]);
92+
});
93+
94+
it('should handle interpolation with duplicate placeholder names', () => {
95+
let translations: {[key: string]: string} = {};
96+
translations[id(new Message('<ph name="FIRST"/> and <ph name="FIRST_1"/>', null, null))] =
97+
'<ph name="FIRST_1"/> or <ph name="FIRST"/>';
98+
99+
expect(
100+
humanizeDom(parse(
101+
`<div value='{{a //i18n(ph="FIRST")}} and {{b //i18n(ph="FIRST")}}' i18n-value></div>`,
102+
translations)))
103+
.toEqual([
104+
[HtmlElementAst, 'div', 0],
105+
[HtmlAttrAst, 'value', '{{b //i18n(ph="FIRST")}} or {{a //i18n(ph="FIRST")}}']
106+
]);
107+
});
108+
79109
it("should handle nested html", () => {
80110
let translations: {[key: string]: string} = {};
81111
translations[id(new Message('<ph name="e0">a</ph><ph name="e2">b</ph>', null, null))] =
@@ -299,7 +329,7 @@ export function main() {
299329

300330
expect(
301331
humanizeErrors(parse("<div value='hi {{a}}' i18n-value></div>", translations).errors))
302-
.toEqual(["Invalid interpolation index '99'"]);
332+
.toEqual(["Invalid interpolation name '99'"]);
303333
});
304334

305335
});
@@ -308,4 +338,4 @@ export function main() {
308338

309339
function humanizeErrors(errors: ParseError[]): string[] {
310340
return errors.map(error => error.msg);
311-
}
341+
}

modules/angular2/test/i18n/message_extractor_spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,47 @@ export function main() {
9393
.toEqual([new Message('Hi <ph name="0"/> and <ph name="1"/>', null, null)]);
9494
});
9595

96+
it('should replace interpolation with named placeholders if provided (text nodes)', () => {
97+
let res = extractor.extract(`
98+
<div i18n>Hi {{one //i18n(ph="FIRST")}} and {{two //i18n(ph="SECOND")}}</div>`,
99+
'someurl');
100+
expect(res.messages)
101+
.toEqual([
102+
new Message('<ph name="t0">Hi <ph name="FIRST"/> and <ph name="SECOND"/></ph>', null,
103+
null)
104+
]);
105+
});
106+
107+
it('should replace interpolation with named placeholders if provided (attributes)', () => {
108+
let res = extractor.extract(`
109+
<div title='Hi {{one //i18n(ph="FIRST")}} and {{two //i18n(ph="SECOND")}}'
110+
i18n-title></div>`,
111+
'someurl');
112+
expect(res.messages)
113+
.toEqual([new Message('Hi <ph name="FIRST"/> and <ph name="SECOND"/>', null, null)]);
114+
});
115+
116+
it('should match named placeholders with extra spacing', () => {
117+
let res = extractor.extract(`
118+
<div title='Hi {{one // i18n ( ph = "FIRST" )}} and {{two // i18n ( ph = "SECOND" )}}'
119+
i18n-title></div>`,
120+
'someurl');
121+
expect(res.messages)
122+
.toEqual([new Message('Hi <ph name="FIRST"/> and <ph name="SECOND"/>', null, null)]);
123+
});
124+
125+
it('should suffix duplicate placeholder names with numbers', () => {
126+
let res = extractor.extract(`
127+
<div title='Hi {{one //i18n(ph="FIRST")}} and {{two //i18n(ph="FIRST")}} and {{three //i18n(ph="FIRST")}}'
128+
i18n-title></div>`,
129+
'someurl');
130+
expect(res.messages)
131+
.toEqual([
132+
new Message('Hi <ph name="FIRST"/> and <ph name="FIRST_1"/> and <ph name="FIRST_2"/>',
133+
null, null)
134+
]);
135+
});
136+
96137
it("should handle html content", () => {
97138
let res = extractor.extract(
98139
'<div i18n><div attr="value">zero<div>one</div></div><div>two</div></div>', "someurl");

0 commit comments

Comments
 (0)