X Tutup
Skip to content

Commit 095db67

Browse files
committed
feat(i18n): implement a simple version of message extractor
Closes #7454
1 parent 70d18b5 commit 095db67

File tree

3 files changed

+424
-1
lines changed

3 files changed

+424
-1
lines changed

modules/angular2/src/compiler/html_parser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ class TreeBuilder {
102102
var text = this._advanceIf(HtmlTokenType.RAW_TEXT);
103103
this._advanceIf(HtmlTokenType.COMMENT_END);
104104
var value = isPresent(text) ? text.parts[0].trim() : null;
105-
this._addToParent(new HtmlCommentAst(value, token.sourceSpan))
105+
this._addToParent(new HtmlCommentAst(value, token.sourceSpan));
106106
}
107107

108108
private _consumeText(token: HtmlToken) {
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import {HtmlParser} from 'angular2/src/compiler/html_parser';
2+
import {ParseSourceSpan, ParseError} from 'angular2/src/compiler/parse_util';
3+
import {
4+
HtmlAst,
5+
HtmlAstVisitor,
6+
HtmlElementAst,
7+
HtmlAttrAst,
8+
HtmlTextAst,
9+
HtmlCommentAst,
10+
htmlVisitAll
11+
} from 'angular2/src/compiler/html_ast';
12+
import {isPresent, isBlank} from 'angular2/src/facade/lang';
13+
import {StringMapWrapper} from 'angular2/src/facade/collection';
14+
import {Parser} from 'angular2/src/core/change_detection/parser/parser';
15+
import {Interpolation} from 'angular2/src/core/change_detection/parser/ast';
16+
17+
const I18N_ATTR = "i18n";
18+
const I18N_ATTR_PREFIX = "i18n-";
19+
20+
/**
21+
* A message extracted from a template.
22+
*
23+
* The identity of a message is comprised of `content` and `meaning`.
24+
*
25+
* `description` is additional information provided to the translator.
26+
*/
27+
export class Message {
28+
constructor(public content: string, public meaning: string, public description: string) {}
29+
}
30+
31+
/**
32+
* All messages extracted from a template.
33+
*/
34+
export class ExtractionResult {
35+
constructor(public messages: Message[], public errors: ParseError[]) {}
36+
}
37+
38+
/**
39+
* An extraction error.
40+
*/
41+
export class I18nExtractionError extends ParseError {
42+
constructor(span: ParseSourceSpan, msg: string) { super(span, msg); }
43+
}
44+
45+
/**
46+
* Removes duplicate messages.
47+
*
48+
* E.g.
49+
*
50+
* ```
51+
* var m = [new Message("message", "meaning", "desc1"), new Message("message", "meaning",
52+
* "desc2")];
53+
* expect(removeDuplicates(m)).toEqual([new Message("message", "meaning", "desc1")]);
54+
* ```
55+
*/
56+
export function removeDuplicates(messages: Message[]): Message[] {
57+
let uniq: {[key: string]: Message} = {};
58+
messages.forEach(m => {
59+
let key = `$ng__${m.meaning}__|${m.content}`;
60+
if (!StringMapWrapper.contains(uniq, key)) {
61+
uniq[key] = m;
62+
}
63+
});
64+
return StringMapWrapper.values(uniq);
65+
}
66+
67+
/**
68+
* Extracts all messages from a template.
69+
*
70+
* It works like this. First, the extractor uses the provided html parser to get
71+
* the html AST of the template. Then it partitions the root nodes into parts.
72+
* Everything between two i18n comments becomes a single part. Every other nodes becomes
73+
* a part too.
74+
*
75+
* We process every part as follows. Say we have a part A.
76+
*
77+
* If the part has the i18n attribute, it gets converted into a message.
78+
* And we do not recurse into that part, except to extract messages from the attributes.
79+
*
80+
* If the part doesn't have the i18n attribute, we recurse into that part and
81+
* partition its children.
82+
*
83+
* While walking the AST we also remove i18n attributes from messages.
84+
*/
85+
export class MessageExtractor {
86+
messages: Message[];
87+
errors: ParseError[];
88+
89+
constructor(private _htmlParser: HtmlParser, private _parser: Parser) {}
90+
91+
extract(template: string, sourceUrl: string): ExtractionResult {
92+
this.messages = [];
93+
this.errors = [];
94+
95+
let res = this._htmlParser.parse(template, sourceUrl);
96+
if (res.errors.length > 0) {
97+
return new ExtractionResult([], res.errors);
98+
} else {
99+
let ps = this._partition(res.rootNodes);
100+
ps.forEach(p => this._extractMessagesFromPart(p));
101+
return new ExtractionResult(this.messages, this.errors);
102+
}
103+
}
104+
105+
private _extractMessagesFromPart(p: _Part): void {
106+
if (p.hasI18n) {
107+
this.messages.push(new Message(_stringifyNodes(p.children, this._parser), _meaning(p.i18n),
108+
_description(p.i18n)));
109+
this._recurseToExtractMessagesFromAttributes(p.children);
110+
} else {
111+
this._recurse(p.children);
112+
}
113+
114+
if (isPresent(p.rootElement)) {
115+
this._extractMessagesFromAttributes(p.rootElement);
116+
}
117+
}
118+
119+
private _recurse(nodes: HtmlAst[]): void {
120+
let ps = this._partition(nodes);
121+
ps.forEach(p => this._extractMessagesFromPart(p));
122+
}
123+
124+
private _recurseToExtractMessagesFromAttributes(nodes: HtmlAst[]): void {
125+
nodes.forEach(n => {
126+
if (n instanceof HtmlElementAst) {
127+
this._extractMessagesFromAttributes(n);
128+
this._recurseToExtractMessagesFromAttributes(n.children);
129+
}
130+
});
131+
}
132+
133+
private _extractMessagesFromAttributes(p: HtmlElementAst): void {
134+
p.attrs.forEach(attr => {
135+
if (attr.name.startsWith(I18N_ATTR_PREFIX)) {
136+
let expectedName = attr.name.substring(5);
137+
let matching = p.attrs.filter(a => a.name == expectedName);
138+
139+
if (matching.length > 0) {
140+
let value = _removeInterpolation(matching[0].value, p.sourceSpan, this._parser);
141+
this.messages.push(new Message(value, _meaning(attr.value), _description(attr.value)));
142+
} else {
143+
this.errors.push(
144+
new I18nExtractionError(p.sourceSpan, `Missing attribute '${expectedName}'.`));
145+
}
146+
}
147+
});
148+
}
149+
150+
// Man, this is so ugly!
151+
private _partition(nodes: HtmlAst[]): _Part[] {
152+
let res = [];
153+
154+
for (let i = 0; i < nodes.length; ++i) {
155+
let n = nodes[i];
156+
let temp = [];
157+
if (_isOpeningComment(n)) {
158+
let i18n = (<HtmlCommentAst>n).value.substring(5).trim();
159+
i++;
160+
while (!_isClosingComment(nodes[i])) {
161+
temp.push(nodes[i++]);
162+
if (i === nodes.length) {
163+
this.errors.push(
164+
new I18nExtractionError(n.sourceSpan, "Missing closing 'i18n' comment."));
165+
break;
166+
}
167+
}
168+
res.push(new _Part(null, temp, i18n, true));
169+
170+
} else if (n instanceof HtmlElementAst) {
171+
let i18n = _findI18nAttr(n);
172+
res.push(new _Part(n, n.children, isPresent(i18n) ? i18n.value : null, isPresent(i18n)));
173+
}
174+
}
175+
176+
return res;
177+
}
178+
}
179+
180+
class _Part {
181+
constructor(public rootElement: HtmlElementAst, public children: HtmlAst[], public i18n: string,
182+
public hasI18n: boolean) {}
183+
}
184+
185+
function _isOpeningComment(n: HtmlAst): boolean {
186+
return n instanceof HtmlCommentAst && isPresent(n.value) && n.value.startsWith("i18n:");
187+
}
188+
189+
function _isClosingComment(n: HtmlAst): boolean {
190+
return n instanceof HtmlCommentAst && isPresent(n.value) && n.value == "/i18n";
191+
}
192+
193+
function _stringifyNodes(nodes: HtmlAst[], parser: Parser) {
194+
let visitor = new _StringifyVisitor(parser);
195+
return htmlVisitAll(visitor, nodes).join("");
196+
}
197+
198+
class _StringifyVisitor implements HtmlAstVisitor {
199+
constructor(private _parser: Parser) {}
200+
201+
visitElement(ast: HtmlElementAst, context: any): any {
202+
let attrs = this._join(htmlVisitAll(this, ast.attrs), " ");
203+
let children = this._join(htmlVisitAll(this, ast.children), "");
204+
return `<${ast.name} ${attrs}>${children}</${ast.name}>`;
205+
}
206+
207+
visitAttr(ast: HtmlAttrAst, context: any): any {
208+
if (ast.name.startsWith(I18N_ATTR_PREFIX)) {
209+
return "";
210+
} else {
211+
return `${ast.name}="${ast.value}"`;
212+
}
213+
}
214+
215+
visitText(ast: HtmlTextAst, context: any): any {
216+
return _removeInterpolation(ast.value, ast.sourceSpan, this._parser);
217+
}
218+
219+
visitComment(ast: HtmlCommentAst, context: any): any { return ""; }
220+
221+
private _join(strs: string[], str: string): string {
222+
return strs.filter(s => s.length > 0).join(str);
223+
}
224+
}
225+
226+
function _removeInterpolation(value: string, source: ParseSourceSpan, parser: Parser): string {
227+
try {
228+
let parsed = parser.parseInterpolation(value, source.toString());
229+
if (isPresent(parsed)) {
230+
let ast: Interpolation = <any>parsed.ast;
231+
let res = "";
232+
for (let i = 0; i < ast.strings.length; ++i) {
233+
res += ast.strings[i];
234+
if (i != ast.strings.length - 1) {
235+
res += `{{I${i}}}`;
236+
}
237+
}
238+
return res;
239+
} else {
240+
return value;
241+
}
242+
} catch (e) {
243+
return value;
244+
}
245+
}
246+
247+
function _findI18nAttr(p: HtmlElementAst): HtmlAttrAst {
248+
let i18n = p.attrs.filter(a => a.name == I18N_ATTR);
249+
return i18n.length == 0 ? null : i18n[0];
250+
}
251+
252+
function _meaning(i18n: string): string {
253+
if (isBlank(i18n) || i18n == "") return null;
254+
return i18n.split("|")[0];
255+
}
256+
257+
function _description(i18n: string): string {
258+
if (isBlank(i18n) || i18n == "") return null;
259+
let parts = i18n.split("|");
260+
return parts.length > 1 ? parts[1] : null;
261+
}

0 commit comments

Comments
 (0)
X Tutup