OLD | NEW |
| (Empty) |
1 // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file | |
2 // for details. All rights reserved. Use of this source code is governed by a | |
3 // BSD-style license that can be found in the LICENSE file. | |
4 library web_components.build.import_inliner; | |
5 | |
6 import 'dart:async'; | |
7 import 'dart:collection' show LinkedHashMap; | |
8 import 'package:barback/barback.dart'; | |
9 import 'package:code_transformers/assets.dart'; | |
10 import 'package:code_transformers/messages/build_logger.dart'; | |
11 import 'package:html/dom.dart'; | |
12 import 'package:html/dom_parsing.dart' show TreeVisitor; | |
13 import 'package:path/path.dart' as path; | |
14 import 'package:source_span/source_span.dart'; | |
15 import 'common.dart'; | |
16 import 'import_crawler.dart'; | |
17 import 'messages.dart'; | |
18 | |
19 /// Transformer which inlines all html imports found from the entry points. This | |
20 /// deletes all dart scripts found during the inlining, so the | |
21 /// [ScriptCompactorTransformer] should be ran first if there are any dart files | |
22 /// in html imports. | |
23 class ImportInlinerTransformer extends Transformer { | |
24 final List<String> entryPoints; | |
25 final List<String> bindingStartDelimiters; | |
26 | |
27 ImportInlinerTransformer( | |
28 [this.entryPoints, this.bindingStartDelimiters = const []]); | |
29 | |
30 bool isPrimary(AssetId id) { | |
31 if (entryPoints != null) return entryPoints.contains(id.path); | |
32 // If no entry point is supplied, then any html file under web/ or test/ is | |
33 // an entry point. | |
34 return (id.path.startsWith('web/') || id.path.startsWith('test/')) && | |
35 id.path.endsWith('.html'); | |
36 } | |
37 | |
38 apply(Transform transform) { | |
39 var logger = new BuildLogger(transform, convertErrorsToWarnings: true); | |
40 return new ImportInliner(transform, transform.primaryInput.id, logger, | |
41 bindingStartDelimiters: bindingStartDelimiters).run(); | |
42 } | |
43 } | |
44 | |
45 /// Helper class which actually does all the inlining of html imports for a | |
46 /// single entry point. | |
47 class ImportInliner { | |
48 // Can be an AggregateTransform or Transform | |
49 final transform; | |
50 // The primary input to start from. | |
51 final AssetId primaryInput; | |
52 // The logger to use. | |
53 final BuildLogger logger; | |
54 // The start delimiters for template bindings, such as '{{' or '[['. | |
55 final List<String> bindingStartDelimiters; | |
56 | |
57 ImportInliner(this.transform, this.primaryInput, this.logger, | |
58 {this.bindingStartDelimiters: const []}); | |
59 | |
60 Future run() { | |
61 var crawler = new ImportCrawler(transform, primaryInput, logger); | |
62 return crawler.crawlImports().then((imports) { | |
63 var primaryDocument = imports[primaryInput].document; | |
64 | |
65 // Normalize urls in the entry point. | |
66 var changed = new _UrlNormalizer( | |
67 primaryInput, primaryInput, logger, bindingStartDelimiters) | |
68 .visit(primaryDocument); | |
69 | |
70 // Inline things if needed, always have at least one (the entry point). | |
71 if (imports.length > 1) { | |
72 _inlineImports(primaryDocument, imports); | |
73 } else if (!changed && | |
74 primaryDocument.querySelectorAll('link[rel="import"]').where( | |
75 (import) => import.attributes['type'] != 'css').length == | |
76 0) { | |
77 // If there were no url changes and no imports, then we are done. | |
78 return; | |
79 } | |
80 | |
81 primaryDocument | |
82 .querySelectorAll('link[rel="import"]') | |
83 .where((import) => import.attributes['type'] != 'css') | |
84 .forEach((element) => element.remove()); | |
85 | |
86 transform.addOutput( | |
87 new Asset.fromString(primaryInput, primaryDocument.outerHtml)); | |
88 }); | |
89 } | |
90 | |
91 void _inlineImports( | |
92 Document primaryDocument, LinkedHashMap<AssetId, ImportData> imports) { | |
93 // Add a hidden div at the top of the body, this is where we will inline | |
94 // all the imports. | |
95 var importWrapper = new Element.tag('div')..attributes['hidden'] = ''; | |
96 var firstElement = primaryDocument.body.firstChild; | |
97 if (firstElement != null) { | |
98 primaryDocument.body.insertBefore(importWrapper, firstElement); | |
99 } else { | |
100 primaryDocument.body.append(importWrapper); | |
101 } | |
102 | |
103 // Move all scripts/stylesheets/imports into the wrapper to maintain | |
104 // ordering. | |
105 _moveHeadToWrapper(primaryDocument, importWrapper); | |
106 | |
107 // Add all the other imports! | |
108 imports.forEach((AssetId asset, ImportData data) { | |
109 if (asset == primaryInput) return; | |
110 var document = data.document; | |
111 // Remove all dart script tags. | |
112 document | |
113 .querySelectorAll('script[type="$dartType"]') | |
114 .forEach((script) => script.remove()); | |
115 // Normalize urls in attributes and inline css. | |
116 new _UrlNormalizer(data.fromId, asset, logger, bindingStartDelimiters) | |
117 .visit(document); | |
118 // Replace the import with its contents by appending the nodes | |
119 // immediately before the import one at a time, and then removing the | |
120 // import from the document. | |
121 var element = data.element; | |
122 var parent = element.parent; | |
123 document.head.nodes | |
124 .toList(growable: false) | |
125 .forEach((child) => parent.insertBefore(child, element)); | |
126 document.body.nodes | |
127 .toList(growable: false) | |
128 .forEach((child) => parent.insertBefore(child, element)); | |
129 element.remove(); | |
130 }); | |
131 } | |
132 } | |
133 | |
134 /// To preserve the order of scripts with respect to inlined | |
135 /// link rel=import, we move both of those into the body before we do any | |
136 /// inlining. We do not start doing this until the first import is found | |
137 /// however, as some scripts do need to be ran in the head to work | |
138 /// properly (webcomponents.js for instance). | |
139 /// | |
140 /// Note: we do this for stylesheets as well to preserve ordering with | |
141 /// respect to eachother, because stylesheets can be pulled in transitively | |
142 /// from imports. | |
143 void _moveHeadToWrapper(Document doc, Element wrapper) { | |
144 var foundImport = false; | |
145 for (var node in doc.head.nodes.toList(growable: false)) { | |
146 if (node is! Element) continue; | |
147 var tag = node.localName; | |
148 var type = node.attributes['type']; | |
149 var rel = node.attributes['rel']; | |
150 if (tag == 'link' && rel == 'import') foundImport = true; | |
151 if (!foundImport) continue; | |
152 if (tag == 'style' || | |
153 tag == 'script' && | |
154 (type == null || type == jsType || type == dartType) || | |
155 tag == 'link' && (rel == 'stylesheet' || rel == 'import')) { | |
156 // Move the node into the wrapper, where its contents will be placed. | |
157 // This wrapper is a hidden div to prevent inlined html from causing a | |
158 // FOUC. | |
159 wrapper.append(node); | |
160 } | |
161 } | |
162 } | |
163 | |
164 /// Internally adjusts urls in the html that we are about to inline. | |
165 // TODO(jakemac): Everything from here down is almost an exact copy from the | |
166 // polymer package. We should consolidate this logic by either removing it | |
167 // completely from polymer or exposing it publicly here and using that in | |
168 // polymer. | |
169 class _UrlNormalizer extends TreeVisitor { | |
170 /// [AssetId] for the main entry point. | |
171 final AssetId primaryInput; | |
172 | |
173 /// Asset where the original content (and original url) was found. | |
174 final AssetId sourceId; | |
175 | |
176 /// Counter used to ensure that every library name we inject is unique. | |
177 int _count = 0; | |
178 | |
179 /// Path to the top level folder relative to the transform primaryInput. | |
180 /// This should just be some arbitrary # of ../'s. | |
181 final String topLevelPath; | |
182 | |
183 /// Whether or not the normalizer has changed something in the tree. | |
184 bool changed = false; | |
185 | |
186 // The start delimiters for template bindings, such as '{{' or '[['. If these | |
187 // are found before the first `/` in a url, then the url will not be | |
188 // normalized. | |
189 final List<String> bindingStartDelimiters; | |
190 | |
191 final BuildLogger logger; | |
192 | |
193 _UrlNormalizer(AssetId primaryInput, this.sourceId, this.logger, | |
194 this.bindingStartDelimiters) | |
195 : primaryInput = primaryInput, | |
196 topLevelPath = '../' * (path.url.split(primaryInput.path).length - 2); | |
197 | |
198 bool visit(Node node) { | |
199 super.visit(node); | |
200 return changed; | |
201 } | |
202 | |
203 visitElement(Element node) { | |
204 // TODO(jakemac): Support custom elements that extend html elements which | |
205 // have url-like attributes. This probably means keeping a list of which | |
206 // html elements support each url-like attribute. | |
207 if (!isCustomTagName(node.localName)) { | |
208 node.attributes.forEach((name, value) { | |
209 if (_urlAttributes.contains(name)) { | |
210 node.attributes[name] = _newUrl(value, node.sourceSpan); | |
211 changed = value != node.attributes[name]; | |
212 } | |
213 }); | |
214 } | |
215 if (node.localName == 'style') { | |
216 node.text = visitCss(node.text); | |
217 } else if (node.localName == 'script' && | |
218 node.attributes['type'] == dartType && | |
219 !node.attributes.containsKey('src')) { | |
220 changed = true; | |
221 } | |
222 return super.visitElement(node); | |
223 } | |
224 | |
225 static final _url = new RegExp(r'url\(([^)]*)\)', multiLine: true); | |
226 static final _quote = new RegExp('["\']', multiLine: true); | |
227 | |
228 /// Visit the CSS text and replace any relative URLs so we can inline it. | |
229 // Ported from: | |
230 // https://github.com/Polymer/vulcanize/blob/c14f63696797cda18dc3d372b78aa3378
acc691f/lib/vulcan.js#L149 | |
231 // TODO(jmesserly): use csslib here instead? Parsing with RegEx is sadness. | |
232 // Maybe it's reliable enough for finding URLs in CSS? I'm not sure. | |
233 String visitCss(String cssText) { | |
234 var url = spanUrlFor(sourceId, primaryInput, logger); | |
235 var src = new SourceFile(cssText, url: url); | |
236 return cssText.replaceAllMapped(_url, (match) { | |
237 changed = true; | |
238 // Extract the URL, without any surrounding quotes. | |
239 var span = src.span(match.start, match.end); | |
240 var href = match[1].replaceAll(_quote, ''); | |
241 href = _newUrl(href, span); | |
242 return 'url($href)'; | |
243 }); | |
244 } | |
245 | |
246 String _newUrl(String href, SourceSpan span) { | |
247 // We only want to parse the part of the href leading up to the first | |
248 // folder, anything after that is not informative. | |
249 var hrefToParse; | |
250 var firstFolder = href.indexOf('/'); | |
251 if (firstFolder == -1) { | |
252 hrefToParse = href; | |
253 } else if (firstFolder == 0) { | |
254 return href; | |
255 } else { | |
256 // Special case packages and assets urls. | |
257 if (href.contains('packages/')) { | |
258 var suffix = href.substring(href.indexOf('packages/') + 9); | |
259 return '${topLevelPath}packages/$suffix'; | |
260 } else if (href.contains('assets/')) { | |
261 var suffix = href.substring(href.indexOf('assets/') + 7); | |
262 return '${topLevelPath}packages/$suffix'; | |
263 } | |
264 | |
265 hrefToParse = '${href.substring(0, firstFolder + 1)}'; | |
266 } | |
267 | |
268 // If we found a binding before the first `/`, then just return the original | |
269 // href, we can't determine anything about it. | |
270 if (bindingStartDelimiters.any((d) => hrefToParse.contains(d))) return href; | |
271 | |
272 Uri uri; | |
273 // Various template systems introduce invalid characters to uris which would | |
274 // be typically replaced at runtime. Parse errors are assumed to be caused | |
275 // by this, and we just return the original href in that case. | |
276 try { | |
277 uri = Uri.parse(hrefToParse); | |
278 } catch (e) { | |
279 return href; | |
280 } | |
281 if (uri.isAbsolute) return href; | |
282 if (uri.scheme.isNotEmpty) return href; | |
283 if (uri.host.isNotEmpty) return href; | |
284 if (uri.path.isEmpty) return href; // Implies standalone ? or # in URI. | |
285 if (path.isAbsolute(hrefToParse)) return href; | |
286 | |
287 var id = uriToAssetId(sourceId, hrefToParse, logger, span); | |
288 if (id == null) return href; | |
289 | |
290 // Build the new path, placing back any suffixes that we stripped earlier. | |
291 var prefix = | |
292 (firstFolder == -1) ? id.path : id.path.substring(0, id.path.length); | |
293 var suffix = (firstFolder == -1) ? '' : href.substring(firstFolder); | |
294 var newPath = '$prefix$suffix'; | |
295 | |
296 if (newPath.startsWith('lib/')) { | |
297 return '${topLevelPath}packages/${id.package}/${newPath.substring(4)}'; | |
298 } | |
299 | |
300 if (newPath.startsWith('asset/')) { | |
301 return '${topLevelPath}assets/${id.package}/${newPath.substring(6)}'; | |
302 } | |
303 | |
304 if (primaryInput.package != id.package) { | |
305 // Technically we shouldn't get there | |
306 logger.error(internalErrorDontKnowHowToImport | |
307 .create({'target': id, 'source': primaryInput, 'extra': ''}), | |
308 span: span); | |
309 return href; | |
310 } | |
311 | |
312 var builder = path.url; | |
313 return builder.normalize(builder.relative(builder.join('/', newPath), | |
314 from: builder.join('/', builder.dirname(primaryInput.path)))); | |
315 } | |
316 } | |
317 | |
318 /// Returns true if this is a valid custom element name. See: | |
319 /// <http://w3c.github.io/webcomponents/spec/custom/#dfn-custom-element-type> | |
320 bool isCustomTagName(String name) { | |
321 if (name == null || !name.contains('-')) return false; | |
322 return !invalidTagNames.containsKey(name); | |
323 } | |
324 | |
325 /// These names have meaning in SVG or MathML, so they aren't allowed as custom | |
326 /// tags. See [isCustomTagName]. | |
327 const invalidTagNames = const { | |
328 'annotation-xml': '', | |
329 'color-profile': '', | |
330 'font-face': '', | |
331 'font-face-src': '', | |
332 'font-face-uri': '', | |
333 'font-face-format': '', | |
334 'font-face-name': '', | |
335 'missing-glyph': '', | |
336 }; | |
337 | |
338 /// HTML attributes that expect a URL value. | |
339 /// <http://dev.w3.org/html5/spec/section-index.html#attributes-1> | |
340 /// | |
341 /// Every one of these attributes is a URL in every context where it is used in | |
342 /// the DOM. The comments show every DOM element where an attribute can be used. | |
343 /// | |
344 /// The _* version of each attribute is also supported, see http://goo.gl/5av8cU | |
345 const _urlAttributes = const [ | |
346 'action', | |
347 '_action', // in form | |
348 'background', | |
349 '_background', // in body | |
350 'cite', | |
351 '_cite', // in blockquote, del, ins, q | |
352 'data', | |
353 '_data', // in object | |
354 'formaction', | |
355 '_formaction', // in button, input | |
356 'href', | |
357 '_href', // in a, area, link, base, command | |
358 'icon', | |
359 '_icon', // in command | |
360 'manifest', | |
361 '_manifest', // in html | |
362 'poster', | |
363 '_poster', // in video | |
364 'src', | |
365 '_src', // in audio, embed, iframe, img, input, script, source, track,video | |
366 ]; | |
OLD | NEW |