dropflow
Version:
A small CSS2 document renderer built from specifications
483 lines (482 loc) • 19.5 kB
JavaScript
// 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;
}