/** * @license * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. * This code may only be used under the BSD style license found at * http://polymer.github.io/LICENSE.txt * The complete set of authors may be found at * http://polymer.github.io/AUTHORS.txt * The complete set of contributors may be found at * http://polymer.github.io/CONTRIBUTORS.txt * Code distributed by Google as part of the polymer project is also * subject to an additional IP rights grant found at * http://polymer.github.io/PATENTS.txt */ import * as dom5 from 'dom5/lib/index-next'; import cssWhat = require('css-what'); import {ParsedHtmlDocument, SourceRange, Replacement} from 'prax-analyzer'; // Attributes that are on every HTMLElement. export const sharedAttributes = new Set([ // From https://html.spec.whatwg.org/multipage/dom.html#htmlelement 'title', 'lang', 'translate', 'dir', 'hidden', 'tabindex', 'accesskey', 'draggable', 'spellcheck', 'innertext', 'contextmenu', // https://html.spec.whatwg.org/multipage/interaction.html#elementcontenteditable 'contenteditable', // https://dom.spec.whatwg.org/#interface-element 'id', 'class', 'slot', // https://html.spec.whatwg.org/multipage/dom.html#global-attributes 'itemid', 'itemprop', 'itemref', 'itemscope', 'itemtype', 'is', 'style', // aria-* http://www.w3.org/TR/wai-aria/states_and_properties#state_prop_def // role: http://www.w3.org/TR/wai-aria/host_languages#host_general_role 'aria-activedescendant', 'aria-atomic', 'aria-autocomplete', 'aria-busy', 'aria-checked', 'aria-controls', 'aria-describedby', 'aria-disabled', 'aria-dropeffect', 'aria-expanded', 'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid', 'aria-label', 'aria-labelledby', 'aria-level', 'aria-live', 'aria-multiline', 'aria-multiselectable', 'aria-orientation', 'aria-owns', 'aria-posinset', 'aria-pressed', 'aria-readonly', 'aria-relevant', 'aria-required', 'aria-selected', 'aria-setsize', 'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow', 'aria-valuetext', 'role', ]); // Properties that are on every HTMLElement export const sharedProperties = new Set([ // From https://html.spec.whatwg.org/multipage/dom.html#htmlelement 'title', 'lang', 'translate', 'dir', 'hidden', 'tabIndex', 'accessKey', 'draggable', 'spellcheck', 'innerText', // https://html.spec.whatwg.org/multipage/interaction.html#elementcontenteditable 'contentEditable', 'isContentEditable', // https://dom.spec.whatwg.org/#interface-element 'id', 'className', 'slot', 'is', ]); /** * If the given node is a text node, add the given addition indentation to * each line of its contents. */ export function addIndentation( textNode: dom5.Node, additionalIndentation = ' ') { if (!dom5.isTextNode(textNode)) { return; } const text = dom5.getTextContent(textNode); const indentedText = text.split('\n') .map((line) => { return line.length > 0 ? additionalIndentation + line : line; }) .join('\n'); dom5.setTextContent(textNode, indentedText); } /** * Estimates the leading indentation of direct children inside the given node. * * Assumes that the given element is written in one of these styles: * * * * * * * * And not like: * */ export function getIndentationInside(parentNode: dom5.Node) { if (!parentNode.childNodes || parentNode.childNodes.length === 0) { return ''; } const firstChild = parentNode.childNodes[0]; if (!dom5.isTextNode(firstChild)) { return ''; } const text = dom5.getTextContent(firstChild); const match = text.match(/(^|\n)([ \t]+)/); if (!match) { return ''; } // If the it's an empty node with just one line of whitespace, like this: //
//
// Then the indentation of actual content inside is one level deeper than // the whitespace on that second line. if (parentNode.childNodes.length === 1 && text.match(/^\n[ \t]+$/)) { return match[2] + ' '; } return match[2]; } /** * Converts a css selector into a dom5 predicate. * * This is intended for handling only selectors that match an individual element * in isolation, it does throws if the selector talks about relationships * between elements like `.foo .bar` or `.foo > .bar`. * * Set `isPolymerTemplate` to true to apply selectors in data-bound attributes, * e.g. `.foo.bar` would return both nodes of the following template: * *
*
*/ export function elementSelectorToPredicate( simpleSelector: string, isPolymerTemplate: boolean = false): dom5.Predicate { const parsed = cssWhat(simpleSelector); // The output of cssWhat is two levels of arrays. The outer level are any // selectors joined with a comma, so it matches if any of the inner selectors // match. The inner array are simple selectors like `.foo` and `#bar` which // must all match. return dom5.predicates.OR(...parsed.map((simpleSelectors) => { return dom5.predicates.AND(...simpleSelectors.map( (selector) => simpleSelectorToPredicate(selector, isPolymerTemplate))); })); } function simpleSelectorToPredicate( selector: cssWhat.Simple, isPolymerTemplate: boolean) { switch (selector.type) { case 'adjacent': case 'child': case 'descendant': case 'parent': case 'sibling': case 'pseudo': throw new Error(`Unsupported CSS operator: ${selector.type}`); case 'attribute': if (isPolymerTemplate) { return dom5.predicates.OR( attributeSelectorToPredicate(selector), attributeSelectorToPredicate( {...selector, name: selector.name + '$'})); } return attributeSelectorToPredicate(selector); case 'tag': return dom5.predicates.hasTagName(selector.name); case 'universal': return () => true; } const never: never = selector; throw new Error(`Unexpected node type from css parser: ${never}`); } function attributeSelectorToPredicate(selector: cssWhat.Attribute): dom5.Predicate { switch (selector.action) { case 'exists': return dom5.predicates.hasAttr(selector.name); case 'equals': return dom5.predicates.hasAttrValue(selector.name, selector.value); case 'start': return (el) => { const attrValue = dom5.getAttribute(el, selector.name); return attrValue != null && attrValue.startsWith(selector.value); }; case 'end': return (el) => { const attrValue = dom5.getAttribute(el, selector.name); return attrValue != null && attrValue.endsWith(selector.value); }; case 'element': return dom5.predicates.hasSpaceSeparatedAttrValue( selector.name, selector.value); case 'any': return (el) => { const attrValue = dom5.getAttribute(el, selector.name); return attrValue != null && attrValue.includes(selector.value); }; } const never: never = selector.action; throw new Error( `Unexpected type of attribute matcher from CSS parser ${never}`); } export function removeTrailingWhitespace( textNode: dom5.Node, parsedDocument: ParsedHtmlDocument) { const prevText = dom5.getTextContent(textNode); const match = prevText.match(/\n?[ \t]*$/); if (!match) { return; } const range = parsedDocument.sourceRangeForNode(textNode)!; const lengthOfPreviousLine = parsedDocument.newlineIndexes[range.end.line - 1] - (parsedDocument.newlineIndexes[range.end.line - 2] || -1) - 1; const newRange: SourceRange = { ...range, start: { column: lengthOfPreviousLine, line: range.end.line - 1, } }; return {range: newRange, replacementText: ''}; } export function addAttribute( parsedDocument: ParsedHtmlDocument, node: dom5.Node, attribute: string, attributeValue: string): Replacement { const tagRange = parsedDocument.sourceRangeForStartTag(node)!; const range = { file: tagRange.file, start: {line: tagRange.end.line, column: tagRange.end.column - 1}, end: {line: tagRange.end.line, column: tagRange.end.column - 1} }; const replacementText = ` ${attribute}="${attributeValue}"`; return {replacementText, range}; } export function prependContentInto( parsedDocument: ParsedHtmlDocument, node: dom5.Node, replacementText: string): Replacement { const tagRange = parsedDocument.sourceRangeForStartTag(node)!; const range = { file: tagRange.file, start: {line: tagRange.end.line, column: tagRange.end.column}, end: {line: tagRange.end.line, column: tagRange.end.column} }; return {replacementText, range}; } export function insertContentAfter( parsedDocument: ParsedHtmlDocument, node: dom5.Node, replacementText: string): Replacement { const tagRange = parsedDocument.sourceRangeForNode(node)!; const range = { file: tagRange.file, start: {line: tagRange.end.line, column: tagRange.end.column}, end: {line: tagRange.end.line, column: tagRange.end.column} }; return {replacementText, range}; } export function removeNode( parsedDocument: ParsedHtmlDocument, node: dom5.Node): Replacement[] { const parentChildren = node.parentNode!.childNodes!; const prevNode = parentChildren[parentChildren.indexOf(node)! - 1]; const fix: Replacement[] = []; if (prevNode && dom5.isTextNode(prevNode)) { const trailingWhiteSpace = removeTrailingWhitespace(prevNode, parsedDocument); if (trailingWhiteSpace) { fix.push(trailingWhiteSpace); } } fix.push( {replacementText: '', range: parsedDocument.sourceRangeForNode(node)!}); return fix; }