OLD | NEW |
(Empty) | |
| 1 // Copyright (c) 2013, 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 |
| 5 /** |
| 6 * Part of the template compilation that concerns with extracting information |
| 7 * from the HTML parse tree. |
| 8 */ |
| 9 library analyzer; |
| 10 |
| 11 import 'package:html5lib/dom.dart'; |
| 12 import 'package:html5lib/dom_parsing.dart'; |
| 13 import 'package:source_maps/span.dart' hide SourceFile; |
| 14 |
| 15 import 'custom_tag_name.dart'; |
| 16 import 'dart_parser.dart' show parseDartCode; |
| 17 import 'files.dart'; |
| 18 import 'info.dart'; |
| 19 import 'messages.dart'; |
| 20 import 'summary.dart'; |
| 21 |
| 22 /** |
| 23 * Finds custom elements in this file and the list of referenced files with |
| 24 * component declarations. This is the first pass of analysis on a file. |
| 25 * |
| 26 * Adds emitted error/warning messages to [messages], if [messages] is |
| 27 * supplied. |
| 28 */ |
| 29 FileInfo analyzeDefinitions(GlobalInfo global, UrlInfo inputUrl, |
| 30 Document document, String packageRoot, |
| 31 Messages messages, {bool isEntryPoint: false}) { |
| 32 var result = new FileInfo(inputUrl, isEntryPoint); |
| 33 var loader = new _ElementLoader(global, result, packageRoot, messages); |
| 34 loader.visit(document); |
| 35 return result; |
| 36 } |
| 37 |
| 38 /** |
| 39 * Extract relevant information from all files found from the root document. |
| 40 * |
| 41 * Adds emitted error/warning messages to [messages], if [messages] is |
| 42 * supplied. |
| 43 */ |
| 44 void analyzeFile(SourceFile file, Map<String, FileInfo> info, |
| 45 Iterator<int> uniqueIds, GlobalInfo global, |
| 46 Messages messages, emulateScopedCss) { |
| 47 var fileInfo = info[file.path]; |
| 48 var analyzer = new _Analyzer(fileInfo, uniqueIds, global, messages, |
| 49 emulateScopedCss); |
| 50 analyzer._normalize(fileInfo, info); |
| 51 analyzer.visit(file.document); |
| 52 } |
| 53 |
| 54 |
| 55 /** A visitor that walks the HTML to extract all the relevant information. */ |
| 56 class _Analyzer extends TreeVisitor { |
| 57 final FileInfo _fileInfo; |
| 58 LibraryInfo _currentInfo; |
| 59 Iterator<int> _uniqueIds; |
| 60 GlobalInfo _global; |
| 61 Messages _messages; |
| 62 |
| 63 int _generatedClassNumber = 0; |
| 64 |
| 65 /** |
| 66 * Whether to keep indentation spaces. Break lines and indentation spaces |
| 67 * within templates are preserved in HTML. When users specify the attribute |
| 68 * 'indentation="remove"' on a template tag, we'll trim those indentation |
| 69 * spaces that occur within that tag and its decendants. If any decendant |
| 70 * specifies 'indentation="preserve"', then we'll switch back to the normal |
| 71 * behavior. |
| 72 */ |
| 73 bool _keepIndentationSpaces = true; |
| 74 |
| 75 final bool _emulateScopedCss; |
| 76 |
| 77 _Analyzer(this._fileInfo, this._uniqueIds, this._global, this._messages, |
| 78 this._emulateScopedCss) { |
| 79 _currentInfo = _fileInfo; |
| 80 } |
| 81 |
| 82 void visitElement(Element node) { |
| 83 if (node.tagName == 'script') { |
| 84 // We already extracted script tags in previous phase. |
| 85 return; |
| 86 } |
| 87 |
| 88 if (node.tagName == 'style') { |
| 89 // We've already parsed the CSS. |
| 90 // If this is a component remove the style node. |
| 91 if (_currentInfo is ComponentInfo && _emulateScopedCss) node.remove(); |
| 92 return; |
| 93 } |
| 94 |
| 95 _bindCustomElement(node); |
| 96 |
| 97 var lastInfo = _currentInfo; |
| 98 if (node.tagName == 'polymer-element') { |
| 99 // If element is invalid _ElementLoader already reported an error, but |
| 100 // we skip the body of the element here. |
| 101 var name = node.attributes['name']; |
| 102 if (name == null) return; |
| 103 |
| 104 ComponentInfo component = _fileInfo.components[name]; |
| 105 if (component == null) return; |
| 106 |
| 107 _analyzeComponent(component); |
| 108 |
| 109 _currentInfo = component; |
| 110 |
| 111 // Remove the <element> tag from the tree |
| 112 node.remove(); |
| 113 } |
| 114 |
| 115 node.attributes.forEach((name, value) { |
| 116 if (name.startsWith('on')) { |
| 117 _validateEventHandler(node, name, value); |
| 118 } else if (name == 'pseudo' && _currentInfo is ComponentInfo) { |
| 119 // Any component's custom pseudo-element(s) defined? |
| 120 _processPseudoAttribute(node, value.split(' ')); |
| 121 } |
| 122 }); |
| 123 |
| 124 var keepSpaces = _keepIndentationSpaces; |
| 125 if (node.tagName == 'template' && |
| 126 node.attributes.containsKey('indentation')) { |
| 127 var value = node.attributes['indentation']; |
| 128 if (value != 'remove' && value != 'preserve') { |
| 129 _messages.warning( |
| 130 "Invalid value for 'indentation' ($value). By default we preserve " |
| 131 "the indentation. Valid values are either 'remove' or 'preserve'.", |
| 132 node.sourceSpan); |
| 133 } |
| 134 _keepIndentationSpaces = value != 'remove'; |
| 135 } |
| 136 |
| 137 // Invoke super to visit children. |
| 138 super.visitElement(node); |
| 139 |
| 140 _keepIndentationSpaces = keepSpaces; |
| 141 _currentInfo = lastInfo; |
| 142 |
| 143 if (node.tagName == 'body' || node.parent == null) { |
| 144 _fileInfo.body = node; |
| 145 } |
| 146 } |
| 147 |
| 148 void _analyzeComponent(ComponentInfo component) { |
| 149 var baseTag = component.extendsTag; |
| 150 component.extendsComponent = baseTag == null ? null |
| 151 : _fileInfo.components[baseTag]; |
| 152 if (component.extendsComponent == null && isCustomTag(baseTag)) { |
| 153 _messages.warning( |
| 154 'custom element with tag name ${component.extendsTag} not found.', |
| 155 component.element.sourceSpan); |
| 156 } |
| 157 |
| 158 // Now that the component's code has been loaded, we can validate that the |
| 159 // class exists. |
| 160 component.findClassDeclaration(_messages); |
| 161 } |
| 162 |
| 163 void _bindCustomElement(Element node) { |
| 164 // <fancy-button> |
| 165 var component = _fileInfo.components[node.tagName]; |
| 166 if (component == null) { |
| 167 // TODO(jmesserly): warn for unknown element tags? |
| 168 |
| 169 // <button is="fancy-button"> |
| 170 var componentName = node.attributes['is']; |
| 171 if (componentName != null) { |
| 172 component = _fileInfo.components[componentName]; |
| 173 } else if (isCustomTag(node.tagName)) { |
| 174 componentName = node.tagName; |
| 175 } |
| 176 if (component == null && componentName != null && |
| 177 componentName != 'polymer-element') { |
| 178 _messages.warning( |
| 179 'custom element with tag name $componentName not found.', |
| 180 node.sourceSpan); |
| 181 } |
| 182 } |
| 183 |
| 184 if (component != null) { |
| 185 if (!component.hasConflict) { |
| 186 _currentInfo.usedComponents[component] = true; |
| 187 } |
| 188 |
| 189 var baseTag = component.baseExtendsTag; |
| 190 var nodeTag = node.tagName; |
| 191 var hasIsAttribute = node.attributes.containsKey('is'); |
| 192 |
| 193 if (baseTag != null && !hasIsAttribute) { |
| 194 _messages.warning( |
| 195 'custom element "${component.tagName}" extends from "$baseTag", but' |
| 196 ' this tag will not include the default properties of "$baseTag". ' |
| 197 'To fix this, either write this tag as <$baseTag ' |
| 198 'is="${component.tagName}"> or remove the "extends" attribute from ' |
| 199 'the custom element declaration.', node.sourceSpan); |
| 200 } else if (hasIsAttribute) { |
| 201 if (baseTag == null) { |
| 202 _messages.warning( |
| 203 'custom element "${component.tagName}" doesn\'t declare any type ' |
| 204 'extensions. To fix this, either rewrite this tag as ' |
| 205 '<${component.tagName}> or add \'extends="$nodeTag"\' to ' |
| 206 'the custom element declaration.', node.sourceSpan); |
| 207 } else if (baseTag != nodeTag) { |
| 208 _messages.warning( |
| 209 'custom element "${component.tagName}" extends from "$baseTag". ' |
| 210 'Did you mean to write <$baseTag is="${component.tagName}">?', |
| 211 node.sourceSpan); |
| 212 } |
| 213 } |
| 214 } |
| 215 } |
| 216 |
| 217 void _processPseudoAttribute(Node node, List<String> values) { |
| 218 List mangledValues = []; |
| 219 for (var pseudoElement in values) { |
| 220 if (_global.pseudoElements.containsKey(pseudoElement)) continue; |
| 221 |
| 222 _uniqueIds.moveNext(); |
| 223 var newValue = "${pseudoElement}_${_uniqueIds.current}"; |
| 224 _global.pseudoElements[pseudoElement] = newValue; |
| 225 // Mangled name of pseudo-element. |
| 226 mangledValues.add(newValue); |
| 227 |
| 228 if (!pseudoElement.startsWith('x-')) { |
| 229 // TODO(terry): The name must start with x- otherwise it's not a custom |
| 230 // pseudo-element. May want to relax since components no |
| 231 // longer need to start with x-. See isse #509 on |
| 232 // pseudo-element prefix. |
| 233 _messages.warning("Custom pseudo-element must be prefixed with 'x-'.", |
| 234 node.sourceSpan); |
| 235 } |
| 236 } |
| 237 |
| 238 // Update the pseudo attribute with the new mangled names. |
| 239 node.attributes['pseudo'] = mangledValues.join(' '); |
| 240 } |
| 241 |
| 242 /** |
| 243 * Support for inline event handlers that take expressions. |
| 244 * For example: `on-double-click=myHandler($event, todo)`. |
| 245 */ |
| 246 void _validateEventHandler(Element node, String name, String value) { |
| 247 if (!name.startsWith('on-')) { |
| 248 // TODO(jmesserly): do we need an option to suppress this warning? |
| 249 _messages.warning('Event handler $name will be interpreted as an inline ' |
| 250 'JavaScript event handler. Use the form ' |
| 251 'on-event-name="handlerName" if you want a Dart handler ' |
| 252 'that will automatically update the UI based on model changes.', |
| 253 node.sourceSpan); |
| 254 } |
| 255 |
| 256 if (value.contains('.') || value.contains('(')) { |
| 257 // TODO(sigmund): should we allow more if we use fancy-syntax? |
| 258 _messages.warning('Invalid event handler body "$value". Declare a method ' |
| 259 'in your custom element "void handlerName(event, detail, target)" ' |
| 260 'and use the form on-event-name="handlerName".', |
| 261 node.sourceSpan); |
| 262 } |
| 263 } |
| 264 |
| 265 /** |
| 266 * Normalizes references in [info]. On the [analyzeDefinitions] phase, the |
| 267 * analyzer extracted names of files and components. Here we link those names |
| 268 * to actual info classes. In particular: |
| 269 * * we initialize the [FileInfo.components] map in [info] by importing all |
| 270 * [declaredComponents], |
| 271 * * we scan all [info.componentLinks] and import their |
| 272 * [info.declaredComponents], using [files] to map the href to the file |
| 273 * info. Names in [info] will shadow names from imported files. |
| 274 * * we fill [LibraryInfo.externalCode] on each component declared in |
| 275 * [info]. |
| 276 */ |
| 277 void _normalize(FileInfo info, Map<String, FileInfo> files) { |
| 278 _attachExtenalScript(info, files); |
| 279 |
| 280 for (var component in info.declaredComponents) { |
| 281 _addComponent(info, component); |
| 282 _attachExtenalScript(component, files); |
| 283 } |
| 284 |
| 285 for (var link in info.componentLinks) { |
| 286 var file = files[link.resolvedPath]; |
| 287 // We already issued an error for missing files. |
| 288 if (file == null) continue; |
| 289 file.declaredComponents.forEach((c) => _addComponent(info, c)); |
| 290 } |
| 291 } |
| 292 |
| 293 /** |
| 294 * Stores a direct reference in [info] to a dart source file that was loaded |
| 295 * in a script tag with the 'src' attribute. |
| 296 */ |
| 297 void _attachExtenalScript(LibraryInfo info, Map<String, FileInfo> files) { |
| 298 var externalFile = info.externalFile; |
| 299 if (externalFile != null) { |
| 300 info.externalCode = files[externalFile.resolvedPath]; |
| 301 if (info.externalCode != null) info.externalCode.htmlFile = info; |
| 302 } |
| 303 } |
| 304 |
| 305 /** Adds a component's tag name to the names in scope for [fileInfo]. */ |
| 306 void _addComponent(FileInfo fileInfo, ComponentSummary component) { |
| 307 var existing = fileInfo.components[component.tagName]; |
| 308 if (existing != null) { |
| 309 if (existing == component) { |
| 310 // This is the same exact component as the existing one. |
| 311 return; |
| 312 } |
| 313 |
| 314 if (existing is ComponentInfo && component is! ComponentInfo) { |
| 315 // Components declared in [fileInfo] shadow component names declared in |
| 316 // imported files. |
| 317 return; |
| 318 } |
| 319 |
| 320 if (existing.hasConflict) { |
| 321 // No need to report a second error for the same name. |
| 322 return; |
| 323 } |
| 324 |
| 325 existing.hasConflict = true; |
| 326 |
| 327 if (component is ComponentInfo) { |
| 328 _messages.error('duplicate custom element definition for ' |
| 329 '"${component.tagName}".', existing.sourceSpan); |
| 330 _messages.error('duplicate custom element definition for ' |
| 331 '"${component.tagName}" (second location).', component.sourceSpan); |
| 332 } else { |
| 333 _messages.error('imported duplicate custom element definitions ' |
| 334 'for "${component.tagName}".', existing.sourceSpan); |
| 335 _messages.error('imported duplicate custom element definitions ' |
| 336 'for "${component.tagName}" (second location).', |
| 337 component.sourceSpan); |
| 338 } |
| 339 } else { |
| 340 fileInfo.components[component.tagName] = component; |
| 341 } |
| 342 } |
| 343 } |
| 344 |
| 345 /** A visitor that finds `<link rel="import">` and `<element>` tags. */ |
| 346 class _ElementLoader extends TreeVisitor { |
| 347 final GlobalInfo _global; |
| 348 final FileInfo _fileInfo; |
| 349 LibraryInfo _currentInfo; |
| 350 String _packageRoot; |
| 351 bool _inHead = false; |
| 352 Messages _messages; |
| 353 |
| 354 /** |
| 355 * Adds emitted warning/error messages to [_messages]. [_messages] |
| 356 * must not be null. |
| 357 */ |
| 358 _ElementLoader(this._global, this._fileInfo, this._packageRoot, |
| 359 this._messages) { |
| 360 _currentInfo = _fileInfo; |
| 361 } |
| 362 |
| 363 void visitElement(Element node) { |
| 364 switch (node.tagName) { |
| 365 case 'link': visitLinkElement(node); break; |
| 366 case 'element': |
| 367 _messages.warning('<element> elements are not supported, use' |
| 368 ' <polymer-element> instead', node.sourceSpan); |
| 369 break; |
| 370 case 'polymer-element': |
| 371 visitElementElement(node); |
| 372 break; |
| 373 case 'script': visitScriptElement(node); break; |
| 374 case 'head': |
| 375 var savedInHead = _inHead; |
| 376 _inHead = true; |
| 377 super.visitElement(node); |
| 378 _inHead = savedInHead; |
| 379 break; |
| 380 default: super.visitElement(node); break; |
| 381 } |
| 382 } |
| 383 |
| 384 /** |
| 385 * Process `link rel="import"` as specified in: |
| 386 * <https://dvcs.w3.org/hg/webcomponents/raw-file/tip/spec/components/index.ht
ml#link-type-component> |
| 387 */ |
| 388 void visitLinkElement(Element node) { |
| 389 var rel = node.attributes['rel']; |
| 390 if (rel != 'component' && rel != 'components' && |
| 391 rel != 'import' && rel != 'stylesheet') return; |
| 392 |
| 393 if (!_inHead) { |
| 394 _messages.warning('link rel="$rel" only valid in ' |
| 395 'head.', node.sourceSpan); |
| 396 return; |
| 397 } |
| 398 |
| 399 if (rel == 'component' || rel == 'components') { |
| 400 _messages.warning('import syntax is changing, use ' |
| 401 'rel="import" instead of rel="$rel".', node.sourceSpan); |
| 402 } |
| 403 |
| 404 var href = node.attributes['href']; |
| 405 if (href == null || href == '') { |
| 406 _messages.warning('link rel="$rel" missing href.', |
| 407 node.sourceSpan); |
| 408 return; |
| 409 } |
| 410 |
| 411 bool isStyleSheet = rel == 'stylesheet'; |
| 412 var urlInfo = UrlInfo.resolve(href, _fileInfo.inputUrl, node.sourceSpan, |
| 413 _packageRoot, _messages, ignoreAbsolute: isStyleSheet); |
| 414 if (urlInfo == null) return; |
| 415 if (isStyleSheet) { |
| 416 _fileInfo.styleSheetHrefs.add(urlInfo); |
| 417 } else { |
| 418 _fileInfo.componentLinks.add(urlInfo); |
| 419 } |
| 420 } |
| 421 |
| 422 void visitElementElement(Element node) { |
| 423 // TODO(jmesserly): what do we do in this case? It seems like an <element> |
| 424 // inside a Shadow DOM should be scoped to that <template> tag, and not |
| 425 // visible from the outside. |
| 426 if (_currentInfo is ComponentInfo) { |
| 427 _messages.error('Nested component definitions are not yet supported.', |
| 428 node.sourceSpan); |
| 429 return; |
| 430 } |
| 431 |
| 432 var tagName = node.attributes['name']; |
| 433 var extendsTag = node.attributes['extends']; |
| 434 |
| 435 if (tagName == null) { |
| 436 _messages.error('Missing tag name of the component. Please include an ' |
| 437 'attribute like \'name="your-tag-name"\'.', |
| 438 node.sourceSpan); |
| 439 return; |
| 440 } |
| 441 |
| 442 var component = new ComponentInfo(node, _fileInfo, tagName, extendsTag); |
| 443 _fileInfo.declaredComponents.add(component); |
| 444 _addComponent(component); |
| 445 |
| 446 var lastInfo = _currentInfo; |
| 447 _currentInfo = component; |
| 448 super.visitElement(node); |
| 449 _currentInfo = lastInfo; |
| 450 } |
| 451 |
| 452 /** Adds a component's tag name to the global list. */ |
| 453 void _addComponent(ComponentInfo component) { |
| 454 var existing = _global.components[component.tagName]; |
| 455 if (existing != null) { |
| 456 if (existing.hasConflict) { |
| 457 // No need to report a second error for the same name. |
| 458 return; |
| 459 } |
| 460 |
| 461 existing.hasConflict = true; |
| 462 |
| 463 _messages.error('duplicate custom element definition for ' |
| 464 '"${component.tagName}".', existing.sourceSpan); |
| 465 _messages.error('duplicate custom element definition for ' |
| 466 '"${component.tagName}" (second location).', component.sourceSpan); |
| 467 } else { |
| 468 _global.components[component.tagName] = component; |
| 469 } |
| 470 } |
| 471 |
| 472 void visitScriptElement(Element node) { |
| 473 var scriptType = node.attributes['type']; |
| 474 var src = node.attributes["src"]; |
| 475 |
| 476 if (scriptType == null) { |
| 477 // Note: in html5 leaving off type= is fine, but it defaults to |
| 478 // text/javascript. Because this might be a common error, we warn about it |
| 479 // in two cases: |
| 480 // * an inline script tag in a web component |
| 481 // * a script src= if the src file ends in .dart (component or not) |
| 482 // |
| 483 // The hope is that neither of these cases should break existing valid |
| 484 // code, but that they'll help component authors avoid having their Dart |
| 485 // code accidentally interpreted as JavaScript by the browser. |
| 486 if (src == null && _currentInfo is ComponentInfo) { |
| 487 _messages.warning('script tag in component with no type will ' |
| 488 'be treated as JavaScript. Did you forget type="application/dart"?', |
| 489 node.sourceSpan); |
| 490 } |
| 491 if (src != null && src.endsWith('.dart')) { |
| 492 _messages.warning('script tag with .dart source file but no type will ' |
| 493 'be treated as JavaScript. Did you forget type="application/dart"?', |
| 494 node.sourceSpan); |
| 495 } |
| 496 return; |
| 497 } |
| 498 |
| 499 if (scriptType != 'application/dart') { |
| 500 if (_currentInfo is ComponentInfo) { |
| 501 // TODO(jmesserly): this warning should not be here, but our compiler |
| 502 // does the wrong thing and it could cause surprising behavior, so let |
| 503 // the user know! See issue #340 for more info. |
| 504 // What we should be doing: leave JS component untouched by compiler. |
| 505 _messages.warning('our custom element implementation does not support ' |
| 506 'JavaScript components yet. If this is affecting you please let us ' |
| 507 'know at https://github.com/dart-lang/web-ui/issues/340.', |
| 508 node.sourceSpan); |
| 509 } |
| 510 |
| 511 return; |
| 512 } |
| 513 |
| 514 if (src != null) { |
| 515 if (!src.endsWith('.dart')) { |
| 516 _messages.warning('"application/dart" scripts should ' |
| 517 'use the .dart file extension.', |
| 518 node.sourceSpan); |
| 519 } |
| 520 |
| 521 if (node.innerHtml.trim() != '') { |
| 522 _messages.error('script tag has "src" attribute and also has script ' |
| 523 'text.', node.sourceSpan); |
| 524 } |
| 525 |
| 526 if (_currentInfo.codeAttached) { |
| 527 _tooManyScriptsError(node); |
| 528 } else { |
| 529 _currentInfo.externalFile = UrlInfo.resolve(src, _fileInfo.inputUrl, |
| 530 node.sourceSpan, _packageRoot, _messages); |
| 531 } |
| 532 return; |
| 533 } |
| 534 |
| 535 if (node.nodes.length == 0) return; |
| 536 |
| 537 // I don't think the html5 parser will emit a tree with more than |
| 538 // one child of <script> |
| 539 assert(node.nodes.length == 1); |
| 540 Text text = node.nodes[0]; |
| 541 |
| 542 if (_currentInfo.codeAttached) { |
| 543 _tooManyScriptsError(node); |
| 544 } else if (_currentInfo == _fileInfo && !_fileInfo.isEntryPoint) { |
| 545 _messages.warning('top-level dart code is ignored on ' |
| 546 ' HTML pages that define components, but are not the entry HTML ' |
| 547 'file.', node.sourceSpan); |
| 548 } else { |
| 549 _currentInfo.inlinedCode = parseDartCode( |
| 550 _currentInfo.dartCodeUrl.resolvedPath, text.value, |
| 551 text.sourceSpan.start); |
| 552 if (_currentInfo.userCode.partOf != null) { |
| 553 _messages.error('expected a library, not a part.', |
| 554 node.sourceSpan); |
| 555 } |
| 556 } |
| 557 } |
| 558 |
| 559 void _tooManyScriptsError(Node node) { |
| 560 var location = _currentInfo is ComponentInfo ? |
| 561 'a custom element declaration' : 'the top-level HTML page'; |
| 562 |
| 563 _messages.error('there should be only one dart script tag in $location.', |
| 564 node.sourceSpan); |
| 565 } |
| 566 } |
OLD | NEW |