Index: pkg/polymer/lib/src/analyzer.dart |
diff --git a/pkg/polymer/lib/src/analyzer.dart b/pkg/polymer/lib/src/analyzer.dart |
new file mode 100644 |
index 0000000000000000000000000000000000000000..193e1391a3d4d33b68289ba66e9fff3069ca503f |
--- /dev/null |
+++ b/pkg/polymer/lib/src/analyzer.dart |
@@ -0,0 +1,566 @@ |
+// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file |
+// for details. All rights reserved. Use of this source code is governed by a |
+// BSD-style license that can be found in the LICENSE file. |
+ |
+/** |
+ * Part of the template compilation that concerns with extracting information |
+ * from the HTML parse tree. |
+ */ |
+library analyzer; |
+ |
+import 'package:html5lib/dom.dart'; |
+import 'package:html5lib/dom_parsing.dart'; |
+import 'package:source_maps/span.dart' hide SourceFile; |
+ |
+import 'custom_tag_name.dart'; |
+import 'dart_parser.dart' show parseDartCode; |
+import 'files.dart'; |
+import 'info.dart'; |
+import 'messages.dart'; |
+import 'summary.dart'; |
+ |
+/** |
+ * Finds custom elements in this file and the list of referenced files with |
+ * component declarations. This is the first pass of analysis on a file. |
+ * |
+ * Adds emitted error/warning messages to [messages], if [messages] is |
+ * supplied. |
+ */ |
+FileInfo analyzeDefinitions(GlobalInfo global, UrlInfo inputUrl, |
+ Document document, String packageRoot, |
+ Messages messages, {bool isEntryPoint: false}) { |
+ var result = new FileInfo(inputUrl, isEntryPoint); |
+ var loader = new _ElementLoader(global, result, packageRoot, messages); |
+ loader.visit(document); |
+ return result; |
+} |
+ |
+/** |
+ * Extract relevant information from all files found from the root document. |
+ * |
+ * Adds emitted error/warning messages to [messages], if [messages] is |
+ * supplied. |
+ */ |
+void analyzeFile(SourceFile file, Map<String, FileInfo> info, |
+ Iterator<int> uniqueIds, GlobalInfo global, |
+ Messages messages, emulateScopedCss) { |
+ var fileInfo = info[file.path]; |
+ var analyzer = new _Analyzer(fileInfo, uniqueIds, global, messages, |
+ emulateScopedCss); |
+ analyzer._normalize(fileInfo, info); |
+ analyzer.visit(file.document); |
+} |
+ |
+ |
+/** A visitor that walks the HTML to extract all the relevant information. */ |
+class _Analyzer extends TreeVisitor { |
+ final FileInfo _fileInfo; |
+ LibraryInfo _currentInfo; |
+ Iterator<int> _uniqueIds; |
+ GlobalInfo _global; |
+ Messages _messages; |
+ |
+ int _generatedClassNumber = 0; |
+ |
+ /** |
+ * Whether to keep indentation spaces. Break lines and indentation spaces |
+ * within templates are preserved in HTML. When users specify the attribute |
+ * 'indentation="remove"' on a template tag, we'll trim those indentation |
+ * spaces that occur within that tag and its decendants. If any decendant |
+ * specifies 'indentation="preserve"', then we'll switch back to the normal |
+ * behavior. |
+ */ |
+ bool _keepIndentationSpaces = true; |
+ |
+ final bool _emulateScopedCss; |
+ |
+ _Analyzer(this._fileInfo, this._uniqueIds, this._global, this._messages, |
+ this._emulateScopedCss) { |
+ _currentInfo = _fileInfo; |
+ } |
+ |
+ void visitElement(Element node) { |
+ if (node.tagName == 'script') { |
+ // We already extracted script tags in previous phase. |
+ return; |
+ } |
+ |
+ if (node.tagName == 'style') { |
+ // We've already parsed the CSS. |
+ // If this is a component remove the style node. |
+ if (_currentInfo is ComponentInfo && _emulateScopedCss) node.remove(); |
+ return; |
+ } |
+ |
+ _bindCustomElement(node); |
+ |
+ var lastInfo = _currentInfo; |
+ if (node.tagName == 'polymer-element') { |
+ // If element is invalid _ElementLoader already reported an error, but |
+ // we skip the body of the element here. |
+ var name = node.attributes['name']; |
+ if (name == null) return; |
+ |
+ ComponentInfo component = _fileInfo.components[name]; |
+ if (component == null) return; |
+ |
+ _analyzeComponent(component); |
+ |
+ _currentInfo = component; |
+ |
+ // Remove the <element> tag from the tree |
+ node.remove(); |
+ } |
+ |
+ node.attributes.forEach((name, value) { |
+ if (name.startsWith('on')) { |
+ _validateEventHandler(node, name, value); |
+ } else if (name == 'pseudo' && _currentInfo is ComponentInfo) { |
+ // Any component's custom pseudo-element(s) defined? |
+ _processPseudoAttribute(node, value.split(' ')); |
+ } |
+ }); |
+ |
+ var keepSpaces = _keepIndentationSpaces; |
+ if (node.tagName == 'template' && |
+ node.attributes.containsKey('indentation')) { |
+ var value = node.attributes['indentation']; |
+ if (value != 'remove' && value != 'preserve') { |
+ _messages.warning( |
+ "Invalid value for 'indentation' ($value). By default we preserve " |
+ "the indentation. Valid values are either 'remove' or 'preserve'.", |
+ node.sourceSpan); |
+ } |
+ _keepIndentationSpaces = value != 'remove'; |
+ } |
+ |
+ // Invoke super to visit children. |
+ super.visitElement(node); |
+ |
+ _keepIndentationSpaces = keepSpaces; |
+ _currentInfo = lastInfo; |
+ |
+ if (node.tagName == 'body' || node.parent == null) { |
+ _fileInfo.body = node; |
+ } |
+ } |
+ |
+ void _analyzeComponent(ComponentInfo component) { |
+ var baseTag = component.extendsTag; |
+ component.extendsComponent = baseTag == null ? null |
+ : _fileInfo.components[baseTag]; |
+ if (component.extendsComponent == null && isCustomTag(baseTag)) { |
+ _messages.warning( |
+ 'custom element with tag name ${component.extendsTag} not found.', |
+ component.element.sourceSpan); |
+ } |
+ |
+ // Now that the component's code has been loaded, we can validate that the |
+ // class exists. |
+ component.findClassDeclaration(_messages); |
+ } |
+ |
+ void _bindCustomElement(Element node) { |
+ // <fancy-button> |
+ var component = _fileInfo.components[node.tagName]; |
+ if (component == null) { |
+ // TODO(jmesserly): warn for unknown element tags? |
+ |
+ // <button is="fancy-button"> |
+ var componentName = node.attributes['is']; |
+ if (componentName != null) { |
+ component = _fileInfo.components[componentName]; |
+ } else if (isCustomTag(node.tagName)) { |
+ componentName = node.tagName; |
+ } |
+ if (component == null && componentName != null && |
+ componentName != 'polymer-element') { |
+ _messages.warning( |
+ 'custom element with tag name $componentName not found.', |
+ node.sourceSpan); |
+ } |
+ } |
+ |
+ if (component != null) { |
+ if (!component.hasConflict) { |
+ _currentInfo.usedComponents[component] = true; |
+ } |
+ |
+ var baseTag = component.baseExtendsTag; |
+ var nodeTag = node.tagName; |
+ var hasIsAttribute = node.attributes.containsKey('is'); |
+ |
+ if (baseTag != null && !hasIsAttribute) { |
+ _messages.warning( |
+ 'custom element "${component.tagName}" extends from "$baseTag", but' |
+ ' this tag will not include the default properties of "$baseTag". ' |
+ 'To fix this, either write this tag as <$baseTag ' |
+ 'is="${component.tagName}"> or remove the "extends" attribute from ' |
+ 'the custom element declaration.', node.sourceSpan); |
+ } else if (hasIsAttribute) { |
+ if (baseTag == null) { |
+ _messages.warning( |
+ 'custom element "${component.tagName}" doesn\'t declare any type ' |
+ 'extensions. To fix this, either rewrite this tag as ' |
+ '<${component.tagName}> or add \'extends="$nodeTag"\' to ' |
+ 'the custom element declaration.', node.sourceSpan); |
+ } else if (baseTag != nodeTag) { |
+ _messages.warning( |
+ 'custom element "${component.tagName}" extends from "$baseTag". ' |
+ 'Did you mean to write <$baseTag is="${component.tagName}">?', |
+ node.sourceSpan); |
+ } |
+ } |
+ } |
+ } |
+ |
+ void _processPseudoAttribute(Node node, List<String> values) { |
+ List mangledValues = []; |
+ for (var pseudoElement in values) { |
+ if (_global.pseudoElements.containsKey(pseudoElement)) continue; |
+ |
+ _uniqueIds.moveNext(); |
+ var newValue = "${pseudoElement}_${_uniqueIds.current}"; |
+ _global.pseudoElements[pseudoElement] = newValue; |
+ // Mangled name of pseudo-element. |
+ mangledValues.add(newValue); |
+ |
+ if (!pseudoElement.startsWith('x-')) { |
+ // TODO(terry): The name must start with x- otherwise it's not a custom |
+ // pseudo-element. May want to relax since components no |
+ // longer need to start with x-. See isse #509 on |
+ // pseudo-element prefix. |
+ _messages.warning("Custom pseudo-element must be prefixed with 'x-'.", |
+ node.sourceSpan); |
+ } |
+ } |
+ |
+ // Update the pseudo attribute with the new mangled names. |
+ node.attributes['pseudo'] = mangledValues.join(' '); |
+ } |
+ |
+ /** |
+ * Support for inline event handlers that take expressions. |
+ * For example: `on-double-click=myHandler($event, todo)`. |
+ */ |
+ void _validateEventHandler(Element node, String name, String value) { |
+ if (!name.startsWith('on-')) { |
+ // TODO(jmesserly): do we need an option to suppress this warning? |
+ _messages.warning('Event handler $name will be interpreted as an inline ' |
+ 'JavaScript event handler. Use the form ' |
+ 'on-event-name="handlerName" if you want a Dart handler ' |
+ 'that will automatically update the UI based on model changes.', |
+ node.sourceSpan); |
+ } |
+ |
+ if (value.contains('.') || value.contains('(')) { |
+ // TODO(sigmund): should we allow more if we use fancy-syntax? |
+ _messages.warning('Invalid event handler body "$value". Declare a method ' |
+ 'in your custom element "void handlerName(event, detail, target)" ' |
+ 'and use the form on-event-name="handlerName".', |
+ node.sourceSpan); |
+ } |
+ } |
+ |
+ /** |
+ * Normalizes references in [info]. On the [analyzeDefinitions] phase, the |
+ * analyzer extracted names of files and components. Here we link those names |
+ * to actual info classes. In particular: |
+ * * we initialize the [FileInfo.components] map in [info] by importing all |
+ * [declaredComponents], |
+ * * we scan all [info.componentLinks] and import their |
+ * [info.declaredComponents], using [files] to map the href to the file |
+ * info. Names in [info] will shadow names from imported files. |
+ * * we fill [LibraryInfo.externalCode] on each component declared in |
+ * [info]. |
+ */ |
+ void _normalize(FileInfo info, Map<String, FileInfo> files) { |
+ _attachExtenalScript(info, files); |
+ |
+ for (var component in info.declaredComponents) { |
+ _addComponent(info, component); |
+ _attachExtenalScript(component, files); |
+ } |
+ |
+ for (var link in info.componentLinks) { |
+ var file = files[link.resolvedPath]; |
+ // We already issued an error for missing files. |
+ if (file == null) continue; |
+ file.declaredComponents.forEach((c) => _addComponent(info, c)); |
+ } |
+ } |
+ |
+ /** |
+ * Stores a direct reference in [info] to a dart source file that was loaded |
+ * in a script tag with the 'src' attribute. |
+ */ |
+ void _attachExtenalScript(LibraryInfo info, Map<String, FileInfo> files) { |
+ var externalFile = info.externalFile; |
+ if (externalFile != null) { |
+ info.externalCode = files[externalFile.resolvedPath]; |
+ if (info.externalCode != null) info.externalCode.htmlFile = info; |
+ } |
+ } |
+ |
+ /** Adds a component's tag name to the names in scope for [fileInfo]. */ |
+ void _addComponent(FileInfo fileInfo, ComponentSummary component) { |
+ var existing = fileInfo.components[component.tagName]; |
+ if (existing != null) { |
+ if (existing == component) { |
+ // This is the same exact component as the existing one. |
+ return; |
+ } |
+ |
+ if (existing is ComponentInfo && component is! ComponentInfo) { |
+ // Components declared in [fileInfo] shadow component names declared in |
+ // imported files. |
+ return; |
+ } |
+ |
+ if (existing.hasConflict) { |
+ // No need to report a second error for the same name. |
+ return; |
+ } |
+ |
+ existing.hasConflict = true; |
+ |
+ if (component is ComponentInfo) { |
+ _messages.error('duplicate custom element definition for ' |
+ '"${component.tagName}".', existing.sourceSpan); |
+ _messages.error('duplicate custom element definition for ' |
+ '"${component.tagName}" (second location).', component.sourceSpan); |
+ } else { |
+ _messages.error('imported duplicate custom element definitions ' |
+ 'for "${component.tagName}".', existing.sourceSpan); |
+ _messages.error('imported duplicate custom element definitions ' |
+ 'for "${component.tagName}" (second location).', |
+ component.sourceSpan); |
+ } |
+ } else { |
+ fileInfo.components[component.tagName] = component; |
+ } |
+ } |
+} |
+ |
+/** A visitor that finds `<link rel="import">` and `<element>` tags. */ |
+class _ElementLoader extends TreeVisitor { |
+ final GlobalInfo _global; |
+ final FileInfo _fileInfo; |
+ LibraryInfo _currentInfo; |
+ String _packageRoot; |
+ bool _inHead = false; |
+ Messages _messages; |
+ |
+ /** |
+ * Adds emitted warning/error messages to [_messages]. [_messages] |
+ * must not be null. |
+ */ |
+ _ElementLoader(this._global, this._fileInfo, this._packageRoot, |
+ this._messages) { |
+ _currentInfo = _fileInfo; |
+ } |
+ |
+ void visitElement(Element node) { |
+ switch (node.tagName) { |
+ case 'link': visitLinkElement(node); break; |
+ case 'element': |
+ _messages.warning('<element> elements are not supported, use' |
+ ' <polymer-element> instead', node.sourceSpan); |
+ break; |
+ case 'polymer-element': |
+ visitElementElement(node); |
+ break; |
+ case 'script': visitScriptElement(node); break; |
+ case 'head': |
+ var savedInHead = _inHead; |
+ _inHead = true; |
+ super.visitElement(node); |
+ _inHead = savedInHead; |
+ break; |
+ default: super.visitElement(node); break; |
+ } |
+ } |
+ |
+ /** |
+ * Process `link rel="import"` as specified in: |
+ * <https://dvcs.w3.org/hg/webcomponents/raw-file/tip/spec/components/index.html#link-type-component> |
+ */ |
+ void visitLinkElement(Element node) { |
+ var rel = node.attributes['rel']; |
+ if (rel != 'component' && rel != 'components' && |
+ rel != 'import' && rel != 'stylesheet') return; |
+ |
+ if (!_inHead) { |
+ _messages.warning('link rel="$rel" only valid in ' |
+ 'head.', node.sourceSpan); |
+ return; |
+ } |
+ |
+ if (rel == 'component' || rel == 'components') { |
+ _messages.warning('import syntax is changing, use ' |
+ 'rel="import" instead of rel="$rel".', node.sourceSpan); |
+ } |
+ |
+ var href = node.attributes['href']; |
+ if (href == null || href == '') { |
+ _messages.warning('link rel="$rel" missing href.', |
+ node.sourceSpan); |
+ return; |
+ } |
+ |
+ bool isStyleSheet = rel == 'stylesheet'; |
+ var urlInfo = UrlInfo.resolve(href, _fileInfo.inputUrl, node.sourceSpan, |
+ _packageRoot, _messages, ignoreAbsolute: isStyleSheet); |
+ if (urlInfo == null) return; |
+ if (isStyleSheet) { |
+ _fileInfo.styleSheetHrefs.add(urlInfo); |
+ } else { |
+ _fileInfo.componentLinks.add(urlInfo); |
+ } |
+ } |
+ |
+ void visitElementElement(Element node) { |
+ // TODO(jmesserly): what do we do in this case? It seems like an <element> |
+ // inside a Shadow DOM should be scoped to that <template> tag, and not |
+ // visible from the outside. |
+ if (_currentInfo is ComponentInfo) { |
+ _messages.error('Nested component definitions are not yet supported.', |
+ node.sourceSpan); |
+ return; |
+ } |
+ |
+ var tagName = node.attributes['name']; |
+ var extendsTag = node.attributes['extends']; |
+ |
+ if (tagName == null) { |
+ _messages.error('Missing tag name of the component. Please include an ' |
+ 'attribute like \'name="your-tag-name"\'.', |
+ node.sourceSpan); |
+ return; |
+ } |
+ |
+ var component = new ComponentInfo(node, _fileInfo, tagName, extendsTag); |
+ _fileInfo.declaredComponents.add(component); |
+ _addComponent(component); |
+ |
+ var lastInfo = _currentInfo; |
+ _currentInfo = component; |
+ super.visitElement(node); |
+ _currentInfo = lastInfo; |
+ } |
+ |
+ /** Adds a component's tag name to the global list. */ |
+ void _addComponent(ComponentInfo component) { |
+ var existing = _global.components[component.tagName]; |
+ if (existing != null) { |
+ if (existing.hasConflict) { |
+ // No need to report a second error for the same name. |
+ return; |
+ } |
+ |
+ existing.hasConflict = true; |
+ |
+ _messages.error('duplicate custom element definition for ' |
+ '"${component.tagName}".', existing.sourceSpan); |
+ _messages.error('duplicate custom element definition for ' |
+ '"${component.tagName}" (second location).', component.sourceSpan); |
+ } else { |
+ _global.components[component.tagName] = component; |
+ } |
+ } |
+ |
+ void visitScriptElement(Element node) { |
+ var scriptType = node.attributes['type']; |
+ var src = node.attributes["src"]; |
+ |
+ if (scriptType == null) { |
+ // Note: in html5 leaving off type= is fine, but it defaults to |
+ // text/javascript. Because this might be a common error, we warn about it |
+ // in two cases: |
+ // * an inline script tag in a web component |
+ // * a script src= if the src file ends in .dart (component or not) |
+ // |
+ // The hope is that neither of these cases should break existing valid |
+ // code, but that they'll help component authors avoid having their Dart |
+ // code accidentally interpreted as JavaScript by the browser. |
+ if (src == null && _currentInfo is ComponentInfo) { |
+ _messages.warning('script tag in component with no type will ' |
+ 'be treated as JavaScript. Did you forget type="application/dart"?', |
+ node.sourceSpan); |
+ } |
+ if (src != null && src.endsWith('.dart')) { |
+ _messages.warning('script tag with .dart source file but no type will ' |
+ 'be treated as JavaScript. Did you forget type="application/dart"?', |
+ node.sourceSpan); |
+ } |
+ return; |
+ } |
+ |
+ if (scriptType != 'application/dart') { |
+ if (_currentInfo is ComponentInfo) { |
+ // TODO(jmesserly): this warning should not be here, but our compiler |
+ // does the wrong thing and it could cause surprising behavior, so let |
+ // the user know! See issue #340 for more info. |
+ // What we should be doing: leave JS component untouched by compiler. |
+ _messages.warning('our custom element implementation does not support ' |
+ 'JavaScript components yet. If this is affecting you please let us ' |
+ 'know at https://github.com/dart-lang/web-ui/issues/340.', |
+ node.sourceSpan); |
+ } |
+ |
+ return; |
+ } |
+ |
+ if (src != null) { |
+ if (!src.endsWith('.dart')) { |
+ _messages.warning('"application/dart" scripts should ' |
+ 'use the .dart file extension.', |
+ node.sourceSpan); |
+ } |
+ |
+ if (node.innerHtml.trim() != '') { |
+ _messages.error('script tag has "src" attribute and also has script ' |
+ 'text.', node.sourceSpan); |
+ } |
+ |
+ if (_currentInfo.codeAttached) { |
+ _tooManyScriptsError(node); |
+ } else { |
+ _currentInfo.externalFile = UrlInfo.resolve(src, _fileInfo.inputUrl, |
+ node.sourceSpan, _packageRoot, _messages); |
+ } |
+ return; |
+ } |
+ |
+ if (node.nodes.length == 0) return; |
+ |
+ // I don't think the html5 parser will emit a tree with more than |
+ // one child of <script> |
+ assert(node.nodes.length == 1); |
+ Text text = node.nodes[0]; |
+ |
+ if (_currentInfo.codeAttached) { |
+ _tooManyScriptsError(node); |
+ } else if (_currentInfo == _fileInfo && !_fileInfo.isEntryPoint) { |
+ _messages.warning('top-level dart code is ignored on ' |
+ ' HTML pages that define components, but are not the entry HTML ' |
+ 'file.', node.sourceSpan); |
+ } else { |
+ _currentInfo.inlinedCode = parseDartCode( |
+ _currentInfo.dartCodeUrl.resolvedPath, text.value, |
+ text.sourceSpan.start); |
+ if (_currentInfo.userCode.partOf != null) { |
+ _messages.error('expected a library, not a part.', |
+ node.sourceSpan); |
+ } |
+ } |
+ } |
+ |
+ void _tooManyScriptsError(Node node) { |
+ var location = _currentInfo is ComponentInfo ? |
+ 'a custom element declaration' : 'the top-level HTML page'; |
+ |
+ _messages.error('there should be only one dart script tag in $location.', |
+ node.sourceSpan); |
+ } |
+} |