X Tutup
Skip to content

Commit bb9fb21

Browse files
committed
feat(i18n): add custom placeholder names
Closes angular#7799 Closes angular#8057
1 parent b64672b commit bb9fb21

File tree

7 files changed

+189
-18
lines changed

7 files changed

+189
-18
lines changed

modules/angular2/src/compiler/expression_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/compiler/expression_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 {
2224
AST,
@@ -67,7 +69,7 @@ export class Parser {
6769

6870
parseAction(input: string, location: any): ASTWithSource {
6971
this._checkNoInterpolation(input, location);
70-
var tokens = this._lexer.tokenize(input);
72+
var tokens = this._lexer.tokenize(this._stripComments(input));
7173
var ast = new _ParseAST(input, location, tokens, true).parseChain();
7274
return new ASTWithSource(ast, input, location);
7375
}
@@ -96,7 +98,7 @@ export class Parser {
9698
}
9799

98100
this._checkNoInterpolation(input, location);
99-
var tokens = this._lexer.tokenize(input);
101+
var tokens = this._lexer.tokenize(this._stripComments(input));
100102
return new _ParseAST(input, location, tokens, false).parseChain();
101103
}
102104

@@ -122,7 +124,7 @@ export class Parser {
122124
let expressions = [];
123125

124126
for (let i = 0; i < split.expressions.length; ++i) {
125-
var tokens = this._lexer.tokenize(split.expressions[i]);
127+
var tokens = this._lexer.tokenize(this._stripComments(split.expressions[i]));
126128
var ast = new _ParseAST(input, location, tokens, false).parseChain();
127129
expressions.push(ast);
128130
}
@@ -158,6 +160,28 @@ export class Parser {
158160
return new ASTWithSource(new LiteralPrimitive(input), input, location);
159161
}
160162

163+
private _stripComments(input: string): string {
164+
let i = this._commentStart(input);
165+
return isPresent(i) ? input.substring(0, i).trim() : input;
166+
}
167+
168+
private _commentStart(input: string): number {
169+
var outerQuote = null;
170+
for (var i = 0; i < input.length - 1; i++) {
171+
let char = StringWrapper.charCodeAt(input, i);
172+
let nextChar = StringWrapper.charCodeAt(input, i + 1);
173+
174+
if (char === $SLASH && nextChar == $SLASH && isBlank(outerQuote)) return i;
175+
176+
if (outerQuote === char) {
177+
outerQuote = null;
178+
} else if (isBlank(outerQuote) && isQuote(char)) {
179+
outerQuote = char;
180+
}
181+
}
182+
return null;
183+
}
184+
161185
private _checkNoInterpolation(input: string, location: any): void {
162186
var parts = StringWrapper.split(input, INTERPOLATION_REGEXP);
163187
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
@@ -22,14 +22,16 @@ import {
2222
partition,
2323
Part,
2424
stringifyNodes,
25-
meaning
25+
meaning,
26+
getPhNameFromBinding,
27+
dedupePhName
2628
} from './shared';
2729

2830
const _I18N_ATTR = "i18n";
2931
const _PLACEHOLDER_ELEMENT = "ph";
3032
const _NAME_ATTR = "name";
3133
const _I18N_ATTR_PREFIX = "i18n-";
32-
let _PLACEHOLDER_EXPANDED_REGEXP = RegExpWrapper.create(`\\<ph(\\s)+name=("(\\d)+")\\>\\<\\/ph\\>`);
34+
let _PLACEHOLDER_EXPANDED_REGEXP = RegExpWrapper.create(`\\<ph(\\s)+name=("(\\w)+")\\>\\<\\/ph\\>`);
3335

3436
/**
3537
* Creates an i18n-ed version of the parsed template.
@@ -313,19 +315,31 @@ export class I18nHtmlParser implements HtmlParser {
313315

314316
private _replacePlaceholdersWithExpressions(message: string, exps: string[],
315317
sourceSpan: ParseSourceSpan): string {
318+
let expMap = this._buildExprMap(exps);
316319
return RegExpWrapper.replaceAll(_PLACEHOLDER_EXPANDED_REGEXP, message, (match) => {
317320
let nameWithQuotes = match[2];
318321
let name = nameWithQuotes.substring(1, nameWithQuotes.length - 1);
319-
let index = NumberWrapper.parseInt(name, 10);
320-
return this._convertIntoExpression(index, exps, sourceSpan);
322+
return this._convertIntoExpression(name, expMap, sourceSpan);
321323
});
322324
}
323325

324-
private _convertIntoExpression(index: number, exps: string[], sourceSpan: ParseSourceSpan) {
325-
if (index >= 0 && index < exps.length) {
326-
return `{{${exps[index]}}}`;
326+
private _buildExprMap(exps: string[]): Map<string, string> {
327+
let expMap = new Map<string, string>();
328+
let usedNames = new Map<string, number>();
329+
330+
for (var i = 0; i < exps.length; i++) {
331+
let phName = getPhNameFromBinding(exps[i], i);
332+
expMap.set(dedupePhName(usedNames, phName), exps[i]);
333+
}
334+
return expMap;
335+
}
336+
337+
private _convertIntoExpression(name: string, expMap: Map<string, string>,
338+
sourceSpan: ParseSourceSpan) {
339+
if (expMap.has(name)) {
340+
return `{{${expMap.get(name)}}}`;
327341
} else {
328-
throw new I18nError(sourceSpan, `Invalid interpolation index '${index}'`);
342+
throw new I18nError(sourceSpan, `Invalid interpolation name '${name}'`);
329343
}
330344
}
331345
}
@@ -347,4 +361,4 @@ class _CreateNodeMapping implements HtmlAstVisitor {
347361
}
348362

349363
visitComment(ast: HtmlCommentAst, context: any): any { return ""; }
350-
}
364+
}

modules/angular2/src/i18n/shared.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ import {
88
HtmlCommentAst,
99
htmlVisitAll
1010
} from 'angular2/src/compiler/html_ast';
11-
import {isPresent, isBlank} from 'angular2/src/facade/lang';
11+
import {isPresent, isBlank, StringWrapper} from 'angular2/src/facade/lang';
1212
import {Message} from './message';
1313
import {Parser} from 'angular2/src/compiler/expression_parser/parser';
1414

1515
export const I18N_ATTR = "i18n";
1616
export const I18N_ATTR_PREFIX = "i18n-";
17+
var CUSTOM_PH_EXP = /\/\/[\s\S]*i18n[\s\S]*\([\s\S]*ph[\s\S]*=[\s\S]*"([\s\S]*?)"[\s\S]*\)/g;
1718

1819
/**
1920
* An i18n error.
@@ -113,12 +114,15 @@ export function removeInterpolation(value: string, source: ParseSourceSpan,
113114
parser: Parser): string {
114115
try {
115116
let parsed = parser.splitInterpolation(value, source.toString());
117+
let usedNames = new Map<string, number>();
116118
if (isPresent(parsed)) {
117119
let res = "";
118120
for (let i = 0; i < parsed.strings.length; ++i) {
119121
res += parsed.strings[i];
120122
if (i != parsed.strings.length - 1) {
121-
res += `<ph name="${i}"/>`;
123+
let customPhName = getPhNameFromBinding(parsed.expressions[i], i);
124+
customPhName = dedupePhName(usedNames, customPhName);
125+
res += `<ph name="${customPhName}"/>`;
122126
}
123127
}
124128
return res;
@@ -130,6 +134,22 @@ export function removeInterpolation(value: string, source: ParseSourceSpan,
130134
}
131135
}
132136

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

modules/angular2/test/compiler/expression_parser/parser_spec.ts

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

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

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

108113
describe("literals", () => {
@@ -269,6 +274,14 @@ export function main() {
269274
});
270275

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

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

429467
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))] =
@@ -198,7 +228,7 @@ export function main() {
198228

199229
expect(
200230
humanizeErrors(parse("<div value='hi {{a}}' i18n-value></div>", translations).errors))
201-
.toEqual(["Invalid interpolation index '99'"]);
231+
.toEqual(["Invalid interpolation name '99'"]);
202232
});
203233

204234
});
@@ -207,4 +237,4 @@ export function main() {
207237

208238
function humanizeErrors(errors: ParseError[]): string[] {
209239
return errors.map(error => error.msg);
210-
}
240+
}

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)
X Tutup