UNPKG

dropflow

Version:

A small CSS2 document renderer built from specifications

483 lines (482 loc) 19.5 kB
// css-what // // Copyright (c) Felix Böhm // All rights reserved. // // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are met: // // Redistributions of source code must retain the above copyright notice, this // list of conditions and the following disclaimer. // // Redistributions in binary form must reproduce the above copyright notice, // this list of conditions and the following disclaimer in the documentation // and/or other materials provided with the distribution. // // THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY // EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE // DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE // FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL // DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR // SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER // CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT // LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY // OUT OF THE USE OF THIS, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. export var SelectorType; (function (SelectorType) { SelectorType["Attribute"] = "attribute"; SelectorType["Pseudo"] = "pseudo"; SelectorType["PseudoElement"] = "pseudo-element"; SelectorType["Tag"] = "tag"; SelectorType["Universal"] = "universal"; // Traversals SelectorType["Adjacent"] = "adjacent"; SelectorType["Child"] = "child"; SelectorType["Descendant"] = "descendant"; SelectorType["Parent"] = "parent"; SelectorType["Sibling"] = "sibling"; SelectorType["ColumnCombinator"] = "column-combinator"; })(SelectorType || (SelectorType = {})); export var AttributeAction; (function (AttributeAction) { AttributeAction["Any"] = "any"; AttributeAction["Element"] = "element"; AttributeAction["End"] = "end"; AttributeAction["Equals"] = "equals"; AttributeAction["Exists"] = "exists"; AttributeAction["Hyphen"] = "hyphen"; AttributeAction["Not"] = "not"; AttributeAction["Start"] = "start"; })(AttributeAction || (AttributeAction = {})); const reName = /^[^\\#]?(?:\\(?:[\da-f]{1,6}\s?|.)|[\w\-\u00b0-\uFFFF])+/; const reEscape = /\\([\da-f]{1,6}\s?|(\s)|.)/gi; const actionTypes = new Map([ [126 /* CharCode.Tilde */, AttributeAction.Element], [94 /* CharCode.Circumflex */, AttributeAction.Start], [36 /* CharCode.Dollar */, AttributeAction.End], [42 /* CharCode.Asterisk */, AttributeAction.Any], [33 /* CharCode.ExclamationMark */, AttributeAction.Not], [124 /* CharCode.Pipe */, AttributeAction.Hyphen] ]); // Pseudos, whose data property is parsed as well. const unpackPseudos = new Set([ 'has', 'not', 'matches', 'is', 'where', 'host', 'host-context' ]); /** * Pseudo elements defined in CSS Level 1 and CSS Level 2 can be written with * a single colon; eg. :before will turn into ::before. * * @see {@link https://www.w3.org/TR/2018/WD-selectors-4-20181121/#pseudo-element-syntax} */ const pseudosToPseudoElements = new Set([ 'before', 'after', 'first-line', 'first-letter' ]); /** * Checks whether a specific selector is a traversal. * This is useful eg. in swapping the order of elements that * are not traversals. * * @param selector Selector to check. */ function isTraversal(selector) { switch (selector.type) { case SelectorType.Adjacent: case SelectorType.Child: case SelectorType.Descendant: case SelectorType.Parent: case SelectorType.Sibling: case SelectorType.ColumnCombinator: return true; default: return false; } } const stripQuotesFromPseudos = new Set(['contains', 'icontains']); // Unescape function taken from https://github.com/jquery/sizzle/blob/master/src/sizzle.js#L152 function funescape(_, escaped, escapedWhitespace) { const high = parseInt(escaped, 16) - 0x10000; // NaN means non-codepoint return high !== high || escapedWhitespace ? escaped : high < 0 ? // BMP codepoint String.fromCharCode(high + 0x10000) : // Supplemental Plane codepoint (surrogate pair) String.fromCharCode((high >> 10) | 0xd800, (high & 0x3ff) | 0xdc00); } function unescapeCSS(str) { return str.replace(reEscape, funescape); } function isQuote(c) { return c === 39 /* CharCode.SingleQuote */ || c === 34 /* CharCode.DoubleQuote */; } function isWhitespace(c) { return (c === 32 /* CharCode.Space */ || c === 9 /* CharCode.Tab */ || c === 10 /* CharCode.NewLine */ || c === 12 /* CharCode.FormFeed */ || c === 13 /* CharCode.CarriageReturn */); } /** * Parses `selector`, optionally with the passed `options`. * * @param selector Selector to parse. * @param options Options for parsing. * @returns Returns a two-dimensional array. * The first dimension represents selectors separated by commas (eg. `sub1, sub2`), * the second contains the relevant tokens for that selector. */ export function parse(selector) { const subselects = []; const endIndex = parseSelector(subselects, `${selector}`, 0); if (endIndex < selector.length) { throw new Error(`Unmatched selector: ${selector.slice(endIndex)}`); } return subselects; } function parseSelector(subselects, selector, selectorIndex) { let tokens = []; function getName(offset) { const match = selector.slice(selectorIndex + offset).match(reName); if (!match) { throw new Error(`Expected name, found ${selector.slice(selectorIndex)}`); } const [name] = match; selectorIndex += offset + name.length; return unescapeCSS(name); } function stripWhitespace(offset) { selectorIndex += offset; while (selectorIndex < selector.length && isWhitespace(selector.charCodeAt(selectorIndex))) { selectorIndex++; } } function readValueWithParenthesis() { selectorIndex += 1; const start = selectorIndex; let counter = 1; for (; counter > 0 && selectorIndex < selector.length; selectorIndex++) { if (selector.charCodeAt(selectorIndex) === 40 /* CharCode.LeftParenthesis */ && !isEscaped(selectorIndex)) { counter++; } else if (selector.charCodeAt(selectorIndex) === 41 /* CharCode.RightParenthesis */ && !isEscaped(selectorIndex)) { counter--; } } if (counter) { throw new Error('Parenthesis not matched'); } return unescapeCSS(selector.slice(start, selectorIndex - 1)); } function isEscaped(pos) { let slashCount = 0; while (selector.charCodeAt(--pos) === 92 /* CharCode.BackSlash */) slashCount++; return (slashCount & 1) === 1; } function ensureNotTraversal() { if (tokens.length > 0 && isTraversal(tokens[tokens.length - 1])) { throw new Error('Did not expect successive traversals.'); } } function addTraversal(type) { if (tokens.length > 0 && tokens[tokens.length - 1].type === SelectorType.Descendant) { tokens[tokens.length - 1].type = type; return; } ensureNotTraversal(); tokens.push({ type }); } function addSpecialAttribute(name, action) { tokens.push({ type: SelectorType.Attribute, name, action, value: getName(1), namespace: null, ignoreCase: 'quirks' }); } /** * We have finished parsing the current part of the selector. * * Remove descendant tokens at the end if they exist, * and return the last index, so that parsing can be * picked up from here. */ function finalizeSubselector() { if (tokens.length && tokens[tokens.length - 1].type === SelectorType.Descendant) { tokens.pop(); } if (tokens.length === 0) { throw new Error('Empty sub-selector'); } subselects.push(tokens); } stripWhitespace(0); if (selector.length === selectorIndex) { return selectorIndex; } loop: while (selectorIndex < selector.length) { const firstChar = selector.charCodeAt(selectorIndex); switch (firstChar) { // Whitespace case 32 /* CharCode.Space */: case 9 /* CharCode.Tab */: case 10 /* CharCode.NewLine */: case 12 /* CharCode.FormFeed */: case 13 /* CharCode.CarriageReturn */: { if (tokens.length === 0 || tokens[0].type !== SelectorType.Descendant) { ensureNotTraversal(); tokens.push({ type: SelectorType.Descendant }); } stripWhitespace(1); break; } // Traversals case 62 /* CharCode.GreaterThan */: { addTraversal(SelectorType.Child); stripWhitespace(1); break; } case 60 /* CharCode.LessThan */: { addTraversal(SelectorType.Parent); stripWhitespace(1); break; } case 126 /* CharCode.Tilde */: { addTraversal(SelectorType.Sibling); stripWhitespace(1); break; } case 43 /* CharCode.Plus */: { addTraversal(SelectorType.Adjacent); stripWhitespace(1); break; } // Special attribute selectors: .class, #id case 46 /* CharCode.Period */: { addSpecialAttribute('class', AttributeAction.Element); break; } case 35 /* CharCode.Hash */: { addSpecialAttribute('id', AttributeAction.Equals); break; } case 91 /* CharCode.LeftSquareBracket */: { stripWhitespace(1); // Determine attribute name and namespace let name; let namespace = null; if (selector.charCodeAt(selectorIndex) === 124 /* CharCode.Pipe */) { // Equivalent to no namespace name = getName(1); } else if (selector.startsWith('*|', selectorIndex)) { namespace = '*'; name = getName(2); } else { name = getName(0); if (selector.charCodeAt(selectorIndex) === 124 /* CharCode.Pipe */ && selector.charCodeAt(selectorIndex + 1) !== 61 /* CharCode.Equal */) { namespace = name; name = getName(1); } } stripWhitespace(0); // Determine comparison operation let action = AttributeAction.Exists; const possibleAction = actionTypes.get(selector.charCodeAt(selectorIndex)); if (possibleAction) { action = possibleAction; if (selector.charCodeAt(selectorIndex + 1) !== 61 /* CharCode.Equal */) { throw new Error('Expected `=`'); } stripWhitespace(2); } else if (selector.charCodeAt(selectorIndex) === 61 /* CharCode.Equal */) { action = AttributeAction.Equals; stripWhitespace(1); } // Determine value let value = ''; let ignoreCase = null; if (action !== 'exists') { if (isQuote(selector.charCodeAt(selectorIndex))) { const quote = selector.charCodeAt(selectorIndex); let sectionEnd = selectorIndex + 1; while (sectionEnd < selector.length && (selector.charCodeAt(sectionEnd) !== quote || isEscaped(sectionEnd))) { sectionEnd += 1; } if (selector.charCodeAt(sectionEnd) !== quote) { throw new Error('Attribute value didn\'t end'); } value = unescapeCSS(selector.slice(selectorIndex + 1, sectionEnd)); selectorIndex = sectionEnd + 1; } else { const valueStart = selectorIndex; while (selectorIndex < selector.length && ((!isWhitespace(selector.charCodeAt(selectorIndex)) && selector.charCodeAt(selectorIndex) !== 93 /* CharCode.RightSquareBracket */) || isEscaped(selectorIndex))) { selectorIndex += 1; } value = unescapeCSS(selector.slice(valueStart, selectorIndex)); } stripWhitespace(0); // See if we have a force ignore flag const forceIgnore = selector.charCodeAt(selectorIndex) | 0x20; // If the forceIgnore flag is set (either `i` or `s`), use that value if (forceIgnore === 115 /* CharCode.LowerS */) { ignoreCase = false; stripWhitespace(1); } else if (forceIgnore === 105 /* CharCode.LowerI */) { ignoreCase = true; stripWhitespace(1); } } if (selector.charCodeAt(selectorIndex) !== 93 /* CharCode.RightSquareBracket */) { throw new Error('Attribute selector didn\'t terminate'); } selectorIndex += 1; const attributeSelector = { type: SelectorType.Attribute, name, action, value, namespace, ignoreCase, }; tokens.push(attributeSelector); break; } case 58 /* CharCode.Colon */: { if (selector.charCodeAt(selectorIndex + 1) === 58 /* CharCode.Colon */) { tokens.push({ type: SelectorType.PseudoElement, name: getName(2).toLowerCase(), data: selector.charCodeAt(selectorIndex) === 40 /* CharCode.LeftParenthesis */ ? readValueWithParenthesis() : null, }); break; } const name = getName(1).toLowerCase(); if (pseudosToPseudoElements.has(name)) { tokens.push({ type: SelectorType.PseudoElement, name, data: null, }); break; } let data = null; if (selector.charCodeAt(selectorIndex) === 40 /* CharCode.LeftParenthesis */) { if (unpackPseudos.has(name)) { if (isQuote(selector.charCodeAt(selectorIndex + 1))) { throw new Error(`Pseudo-selector ${name} cannot be quoted`); } data = []; selectorIndex = parseSelector(data, selector, selectorIndex + 1); if (selector.charCodeAt(selectorIndex) !== 41 /* CharCode.RightParenthesis */) { throw new Error(`Missing closing parenthesis in :${name} (${selector})`); } selectorIndex += 1; } else { data = readValueWithParenthesis(); if (stripQuotesFromPseudos.has(name)) { const quot = data.charCodeAt(0); if (quot === data.charCodeAt(data.length - 1) && isQuote(quot)) { data = data.slice(1, -1); } } data = unescapeCSS(data); } } tokens.push({ type: SelectorType.Pseudo, name, data }); break; } case 44 /* CharCode.Comma */: { finalizeSubselector(); tokens = []; stripWhitespace(1); break; } default: { if (selector.startsWith('/*', selectorIndex)) { const endIndex = selector.indexOf('*/', selectorIndex + 2); if (endIndex < 0) { throw new Error('Comment was not terminated'); } selectorIndex = endIndex + 2; // Remove leading whitespace if (tokens.length === 0) { stripWhitespace(0); } break; } let namespace = null; let name; if (firstChar === 42 /* CharCode.Asterisk */) { selectorIndex += 1; name = '*'; } else if (firstChar === 124 /* CharCode.Pipe */) { name = ''; if (selector.charCodeAt(selectorIndex + 1) === 124 /* CharCode.Pipe */) { addTraversal(SelectorType.ColumnCombinator); stripWhitespace(2); break; } } else if (reName.test(selector.slice(selectorIndex))) { name = getName(0); } else { break loop; } if (selector.charCodeAt(selectorIndex) === 124 /* CharCode.Pipe */ && selector.charCodeAt(selectorIndex + 1) !== 124 /* CharCode.Pipe */) { namespace = name; if (selector.charCodeAt(selectorIndex + 1) === 42 /* CharCode.Asterisk */) { name = '*'; selectorIndex += 2; } else { name = getName(1); } } tokens.push(name === '*' ? { type: SelectorType.Universal, namespace } : { type: SelectorType.Tag, name, namespace }); } } } finalizeSubselector(); return selectorIndex; }