OLD | NEW |
(Empty) | |
| 1 /// Query selector implementation for html5lib's DOM. |
| 2 library html5lib.src.query; |
| 3 |
| 4 import 'package:csslib/parser.dart' as css; |
| 5 import 'package:csslib/parser.dart' show TokenKind; |
| 6 import 'package:csslib/visitor.dart'; // the CSSOM |
| 7 import 'package:html5lib/dom.dart'; |
| 8 import 'package:html5lib/src/constants.dart' show isWhitespaceCC; |
| 9 |
| 10 bool matches(Node node, String selector) => |
| 11 new SelectorEvaluator().matches(node, _parseSelectorList(selector)); |
| 12 |
| 13 Element querySelector(Node node, String selector) => |
| 14 new SelectorEvaluator().querySelector(node, _parseSelectorList(selector)); |
| 15 |
| 16 List<Element> querySelectorAll(Node node, String selector) { |
| 17 var results = []; |
| 18 new SelectorEvaluator() |
| 19 .querySelectorAll(node, _parseSelectorList(selector), results); |
| 20 return results; |
| 21 } |
| 22 |
| 23 // http://dev.w3.org/csswg/selectors-4/#grouping |
| 24 SelectorGroup _parseSelectorList(String selector) { |
| 25 var errors = []; |
| 26 var group = css.parseSelectorGroup(selector, errors: errors); |
| 27 if (group == null || errors.isNotEmpty) { |
| 28 throw new FormatException("'$selector' is not a valid selector: $errors"); |
| 29 } |
| 30 return group; |
| 31 } |
| 32 |
| 33 class SelectorEvaluator extends Visitor { |
| 34 /// The current HTML element to match against. |
| 35 Element _element; |
| 36 |
| 37 bool matches(Element element, SelectorGroup selector) { |
| 38 _element = element; |
| 39 return visitSelectorGroup(selector); |
| 40 } |
| 41 |
| 42 Element querySelector(Node root, SelectorGroup selector) { |
| 43 for (var node in root.nodes) { |
| 44 if (node is! Element) continue; |
| 45 if (matches(node, selector)) return node; |
| 46 var result = querySelector(node, selector); |
| 47 if (result != null) return result; |
| 48 } |
| 49 return null; |
| 50 } |
| 51 |
| 52 void querySelectorAll(Node root, SelectorGroup selector, |
| 53 List<Element> results) { |
| 54 |
| 55 for (var node in root.nodes) { |
| 56 if (node is! Element) continue; |
| 57 if (matches(node, selector)) results.add(node); |
| 58 querySelectorAll(node, selector, results); |
| 59 } |
| 60 } |
| 61 |
| 62 |
| 63 bool visitSelectorGroup(SelectorGroup group) => |
| 64 group.selectors.any(visitSelector); |
| 65 |
| 66 bool visitSelector(Selector selector) { |
| 67 var old = _element; |
| 68 var result = true; |
| 69 |
| 70 // Note: evaluate selectors right-to-left as it's more efficient. |
| 71 int combinator = null; |
| 72 for (var s in selector.simpleSelectorSequences.reversed) { |
| 73 if (combinator == null) { |
| 74 result = s.simpleSelector.visit(this); |
| 75 } else if (combinator == TokenKind.COMBINATOR_DESCENDANT) { |
| 76 // descendant combinator |
| 77 // http://dev.w3.org/csswg/selectors-4/#descendant-combinators |
| 78 do { |
| 79 _element = _element.parent; |
| 80 } while (_element != null && !s.simpleSelector.visit(this)); |
| 81 |
| 82 if (_element == null) result = false; |
| 83 } else if (combinator == TokenKind.COMBINATOR_TILDE) { |
| 84 // Following-sibling combinator |
| 85 // http://dev.w3.org/csswg/selectors-4/#general-sibling-combinators |
| 86 do { |
| 87 _element = _element.previousElementSibling; |
| 88 } while (_element != null && !s.simpleSelector.visit(this)); |
| 89 |
| 90 if (_element == null) result = false; |
| 91 } |
| 92 |
| 93 if (!result) break; |
| 94 |
| 95 switch (s.combinator) { |
| 96 case TokenKind.COMBINATOR_PLUS: |
| 97 // Next-sibling combinator |
| 98 // http://dev.w3.org/csswg/selectors-4/#adjacent-sibling-combinators |
| 99 _element = _element.previousElementSibling; |
| 100 break; |
| 101 case TokenKind.COMBINATOR_GREATER: |
| 102 // Child combinator |
| 103 // http://dev.w3.org/csswg/selectors-4/#child-combinators |
| 104 _element = _element.parent; |
| 105 break; |
| 106 case TokenKind.COMBINATOR_DESCENDANT: |
| 107 case TokenKind.COMBINATOR_TILDE: |
| 108 // We need to iterate through all siblings or parents. |
| 109 // For now, just remember what the combinator was. |
| 110 combinator = s.combinator; |
| 111 break; |
| 112 case TokenKind.COMBINATOR_NONE: break; |
| 113 default: throw _unsupported(selector); |
| 114 } |
| 115 |
| 116 if (_element == null) { |
| 117 result = false; |
| 118 break; |
| 119 } |
| 120 } |
| 121 |
| 122 _element = old; |
| 123 return result; |
| 124 } |
| 125 |
| 126 _unimplemented(SimpleSelector selector) => |
| 127 new UnimplementedError("'$selector' selector of type " |
| 128 "${selector.runtimeType} is not implemented"); |
| 129 |
| 130 _unsupported(selector) => |
| 131 new FormatException("'$selector' is not a valid selector"); |
| 132 |
| 133 bool visitPseudoClassSelector(PseudoClassSelector selector) { |
| 134 switch (selector.name) { |
| 135 // http://dev.w3.org/csswg/selectors-4/#structural-pseudos |
| 136 |
| 137 // http://dev.w3.org/csswg/selectors-4/#the-root-pseudo |
| 138 case 'root': |
| 139 // TODO(jmesserly): fix when we have a .ownerDocument pointer |
| 140 // return _element == _element.ownerDocument.rootElement; |
| 141 return _element.localName == 'html' && _element.parentNode == null; |
| 142 |
| 143 // http://dev.w3.org/csswg/selectors-4/#the-empty-pseudo |
| 144 case 'empty': |
| 145 return _element.nodes.any((n) => !(n is Element || |
| 146 n is Text && n.text.isNotEmpty)); |
| 147 |
| 148 // http://dev.w3.org/csswg/selectors-4/#the-blank-pseudo |
| 149 case 'blank': |
| 150 return _element.nodes.any((n) => !(n is Element || |
| 151 n is Text && n.text.runes.any((r) => !isWhitespaceCC(r)))); |
| 152 |
| 153 // http://dev.w3.org/csswg/selectors-4/#the-first-child-pseudo |
| 154 case 'first-child': |
| 155 return _element.previousElementSibling == null; |
| 156 |
| 157 // http://dev.w3.org/csswg/selectors-4/#the-last-child-pseudo |
| 158 case 'last-child': |
| 159 return _element.nextElementSibling == null; |
| 160 |
| 161 // http://dev.w3.org/csswg/selectors-4/#the-only-child-pseudo |
| 162 case 'only-child': |
| 163 return _element.previousElementSibling == null && |
| 164 _element.nextElementSibling == null; |
| 165 |
| 166 // http://dev.w3.org/csswg/selectors-4/#link |
| 167 case 'link': |
| 168 return _element.attributes['href'] != null; |
| 169 |
| 170 case 'visited': |
| 171 // Always return false since we aren't a browser. This is allowed per: |
| 172 // http://dev.w3.org/csswg/selectors-4/#visited-pseudo |
| 173 return false; |
| 174 } |
| 175 |
| 176 // :before, :after, :first-letter/line can't match DOM elements. |
| 177 if (_isLegacyPsuedoClass(selector.name)) return false; |
| 178 |
| 179 throw _unimplemented(selector); |
| 180 } |
| 181 |
| 182 |
| 183 bool visitPseudoElementSelector(PseudoElementSelector selector) { |
| 184 // :before, :after, :first-letter/line can't match DOM elements. |
| 185 if (_isLegacyPsuedoClass(selector.name)) return false; |
| 186 |
| 187 throw _unimplemented(selector); |
| 188 } |
| 189 |
| 190 static bool _isLegacyPsuedoClass(String name) { |
| 191 switch (name) { |
| 192 case 'before': case 'after': case 'first-line': case 'first-letter': |
| 193 return true; |
| 194 default: return false; |
| 195 } |
| 196 } |
| 197 |
| 198 bool visitPseudoElementFunctionSelector(PseudoElementFunctionSelector s) => |
| 199 throw _unimplemented(s); |
| 200 |
| 201 bool visitPseudoClassFunctionSelector(PseudoClassFunctionSelector selector) { |
| 202 switch (selector.name) { |
| 203 // http://dev.w3.org/csswg/selectors-4/#child-index |
| 204 |
| 205 // http://dev.w3.org/csswg/selectors-4/#the-nth-child-pseudo |
| 206 case 'nth-child': |
| 207 // TODO(jmesserly): support An+B syntax too. |
| 208 var exprs = selector.expression.expressions; |
| 209 if (exprs.length == 1 && exprs[0] is LiteralTerm) { |
| 210 LiteralTerm literal = exprs[0]; |
| 211 var parent = _element.parentNode; |
| 212 return parent != null && literal.value > 0 && |
| 213 parent.nodes.indexOf(_element) == literal.value; |
| 214 } |
| 215 break; |
| 216 |
| 217 // http://dev.w3.org/csswg/selectors-4/#the-lang-pseudo |
| 218 case 'lang': |
| 219 // TODO(jmesserly): shouldn't need to get the raw text here, but csslib |
| 220 // gets confused by the "-" in the expression, such as in "es-AR". |
| 221 var toMatch = selector.expression.span.text; |
| 222 var lang = _getInheritedLanguage(_element); |
| 223 // TODO(jmesserly): implement wildcards in level 4 |
| 224 return lang != null && lang.startsWith(toMatch); |
| 225 } |
| 226 throw _unimplemented(selector); |
| 227 } |
| 228 |
| 229 static String _getInheritedLanguage(Node node) { |
| 230 while (node != null) { |
| 231 var lang = node.attributes['lang']; |
| 232 if (lang != null) return lang; |
| 233 node = node.parent; |
| 234 } |
| 235 return null; |
| 236 } |
| 237 |
| 238 bool visitNamespaceSelector(NamespaceSelector selector) { |
| 239 // Match element tag name |
| 240 if (!selector.nameAsSimpleSelector.visit(this)) return false; |
| 241 |
| 242 if (selector.isNamespaceWildcard) return true; |
| 243 |
| 244 if (selector.namespace == '') return _element.namespaceUri == null; |
| 245 |
| 246 throw _unimplemented(selector); |
| 247 } |
| 248 |
| 249 bool visitElementSelector(ElementSelector selector) => |
| 250 selector.isWildcard || _element.localName == selector.name.toLowerCase(); |
| 251 |
| 252 bool visitIdSelector(IdSelector selector) => _element.id == selector.name; |
| 253 |
| 254 bool visitClassSelector(ClassSelector selector) => |
| 255 _element.classes.contains(selector.name); |
| 256 |
| 257 // TODO(jmesserly): negation should support any selectors in level 4, |
| 258 // not just simple selectors. |
| 259 // http://dev.w3.org/csswg/selectors-4/#negation |
| 260 bool visitNegationSelector(NegationSelector selector) => |
| 261 !selector.negationArg.visit(this); |
| 262 |
| 263 bool visitAttributeSelector(AttributeSelector selector) { |
| 264 // Match name first |
| 265 var value = _element.attributes[selector.name.toLowerCase()]; |
| 266 if (value == null) return false; |
| 267 |
| 268 if (selector.operatorKind == TokenKind.NO_MATCH) return true; |
| 269 |
| 270 var select = '${selector.value}'; |
| 271 switch (selector.operatorKind) { |
| 272 case TokenKind.EQUALS: |
| 273 return value == select; |
| 274 case TokenKind.INCLUDES: |
| 275 return value.split(' ').any((v) => v.isNotEmpty && v == select); |
| 276 case TokenKind.DASH_MATCH: |
| 277 return value.startsWith(select) && |
| 278 (value.length == select.length || value[select.length] == '-'); |
| 279 case TokenKind.PREFIX_MATCH: |
| 280 return value.startsWith(select); |
| 281 case TokenKind.SUFFIX_MATCH: |
| 282 return value.endsWith(select); |
| 283 case TokenKind.SUBSTRING_MATCH: |
| 284 return value.contains(select); |
| 285 default: throw _unsupported(selector); |
| 286 } |
| 287 } |
| 288 } |
OLD | NEW |