| 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 |