svelte/src/compiler/compile/css/Selector.ts

301 lines
8.0 KiB

import MagicString from 'magic-string';
import Stylesheet from './Stylesheet';
import { gather_possible_values, UNKNOWN } from './gather_possible_values';
import { Node } from '../../interfaces';
import Component from '../Component';
export default class Selector {
node: Node;
stylesheet: Stylesheet;
blocks: Block[];
local_blocks: Block[];
used: boolean;
constructor(node: Node, stylesheet: Stylesheet) {
this.node = node;
this.stylesheet = stylesheet;
this.blocks = group_selectors(node);
// take trailing :global(...) selectors out of consideration
let i = this.blocks.length;
while (i > 0) {
if (!this.blocks[i - 1].global) break;
i -= 1;
}
this.local_blocks = this.blocks.slice(0, i);
this.used = this.blocks[0].global;
}
apply(node: Node, stack: Node[]) {
const to_encapsulate: Node[] = [];
apply_selector(this.stylesheet, this.local_blocks.slice(), node, stack.slice(), to_encapsulate);
if (to_encapsulate.length > 0) {
to_encapsulate.filter((_, i) => i === 0 || i === to_encapsulate.length - 1).forEach(({ node, block }) => {
this.stylesheet.nodes_with_css_class.add(node);
block.should_encapsulate = true;
});
this.used = true;
}
}
minify(code: MagicString) {
let c: number = null;
this.blocks.forEach((block, i) => {
if (i > 0) {
if (block.start - c > 1) {
code.overwrite(c, block.start, block.combinator.name || ' ');
}
}
c = block.end;
});
}
transform(code: MagicString, attr: string) {
function encapsulate_block(block: Block) {
let i = block.selectors.length;
while (i--) {
const selector = block.selectors[i];
if (selector.type === 'PseudoElementSelector' || selector.type === 'PseudoClassSelector') continue;
if (selector.type === 'TypeSelector' && selector.name === '*') {
code.overwrite(selector.start, selector.end, attr);
} else {
code.appendLeft(selector.end, attr);
}
break;
}
}
this.blocks.forEach((block, i) => {
if (block.global) {
const selector = block.selectors[0];
const first = selector.children[0];
const last = selector.children[selector.children.length - 1];
code.remove(selector.start, first.start).remove(last.end, selector.end);
}
if (block.should_encapsulate) encapsulate_block(block);
});
}
validate(component: Component) {
this.blocks.forEach((block) => {
let i = block.selectors.length;
while (i-- > 1) {
const selector = block.selectors[i];
if (selector.type === 'PseudoClassSelector' && selector.name === 'global') {
component.error(selector, {
code: `css-invalid-global`,
message: `:global(...) must be the first element in a compound selector`
});
}
}
});
let start = 0;
let end = this.blocks.length;
for (; start < end; start += 1) {
if (!this.blocks[start].global) break;
}
for (; end > start; end -= 1) {
if (!this.blocks[end - 1].global) break;
}
for (let i = start; i < end; i += 1) {
if (this.blocks[i].global) {
component.error(this.blocks[i].selectors[0], {
code: `css-invalid-global`,
message: `:global(...) can be at the start or end of a selector sequence, but not in the middle`
});
}
}
}
}
function apply_selector(stylesheet: Stylesheet, blocks: Block[], node: Node, stack: Node[], to_encapsulate: any[]): boolean {
const block = blocks.pop();
if (!block) return false;
if (!node) {
return blocks.every(block => block.global);
}
let i = block.selectors.length;
while (i--) {
const selector = block.selectors[i];
if (selector.type === 'PseudoClassSelector' && selector.name === 'global') {
// TODO shouldn't see this here... maybe we should enforce that :global(...)
// cannot be sandwiched between non-global selectors?
return false;
}
if (selector.type === 'PseudoClassSelector' || selector.type === 'PseudoElementSelector') {
continue;
}
if (selector.type === 'ClassSelector') {
if (!attribute_matches(node, 'class', selector.name, '~=', false) && !class_matches(node, selector.name)) return false;
}
else if (selector.type === 'IdSelector') {
if (!attribute_matches(node, 'id', selector.name, '=', false)) return false;
}
else if (selector.type === 'AttributeSelector') {
if (!attribute_matches(node, selector.name.name, selector.value && unquote(selector.value), selector.matcher, selector.flags)) return false;
}
else if (selector.type === 'TypeSelector') {
// remove toLowerCase() in v2, when uppercase elements will be forbidden
if (node.name.toLowerCase() !== selector.name.toLowerCase() && selector.name !== '*') return false;
}
else {
// bail. TODO figure out what these could be
to_encapsulate.push({ node, block });
return true;
}
}
if (block.combinator) {
if (block.combinator.type === 'WhiteSpace') {
while (stack.length) {
if (apply_selector(stylesheet, blocks.slice(), stack.pop(), stack, to_encapsulate)) {
to_encapsulate.push({ node, block });
return true;
}
}
if (blocks.every(block => block.global)) {
to_encapsulate.push({ node, block });
return true;
}
return false;
} else if (block.combinator.name === '>') {
if (apply_selector(stylesheet, blocks, stack.pop(), stack, to_encapsulate)) {
to_encapsulate.push({ node, block });
return true;
}
return false;
}
// TODO other combinators
to_encapsulate.push({ node, block });
return true;
}
to_encapsulate.push({ node, block });
return true;
}
const operators = {
'=' : (value: string, flags: string) => new RegExp(`^${value}$`, flags),
'~=': (value: string, flags: string) => new RegExp(`\\b${value}\\b`, flags),
'|=': (value: string, flags: string) => new RegExp(`^${value}(-.+)?$`, flags),
'^=': (value: string, flags: string) => new RegExp(`^${value}`, flags),
'$=': (value: string, flags: string) => new RegExp(`${value}$`, flags),
'*=': (value: string, flags: string) => new RegExp(value, flags)
};
function attribute_matches(node: Node, name: string, expected_value: string, operator: string, case_insensitive: boolean) {
const spread = node.attributes.find(attr => attr.type === 'Spread');
if (spread) return true;
const attr = node.attributes.find((attr: Node) => attr.name === name);
if (!attr) return false;
if (attr.is_true) return operator === null;
if (attr.chunks.length > 1) return true;
if (!expected_value) return true;
const pattern = operators[operator](expected_value, case_insensitive ? 'i' : '');
const value = attr.chunks[0];
if (!value) return false;
if (value.type === 'Text') return pattern.test(value.data);
const possible_values = new Set();
gather_possible_values(value.node, possible_values);
if (possible_values.has(UNKNOWN)) return true;
for (const x of Array.from(possible_values)) { // TypeScript for-of is slightly unlike JS
if (pattern.test(x)) return true;
}
return false;
}
function class_matches(node, name: string) {
return node.classes.some(function(class_directive) {
return class_directive.name === name;
});
}
function unquote(value: Node) {
if (value.type === 'Identifier') return value.name;
const str = value.value;
if (str[0] === str[str.length - 1] && str[0] === "'" || str[0] === '"') {
return str.slice(1, str.length - 1);
}
return str;
}
class Block {
global: boolean;
combinator: Node;
selectors: Node[]
start: number;
end: number;
should_encapsulate: boolean;
constructor(combinator: Node) {
this.combinator = combinator;
this.global = false;
this.selectors = [];
this.start = null;
this.end = null;
this.should_encapsulate = false;
}
add(selector: Node) {
if (this.selectors.length === 0) {
this.start = selector.start;
this.global = selector.type === 'PseudoClassSelector' && selector.name === 'global';
}
this.selectors.push(selector);
this.end = selector.end;
}
}
function group_selectors(selector: Node) {
let block: Block = new Block(null);
const blocks = [block];
selector.children.forEach((child: Node, i: number) => {
if (child.type === 'WhiteSpace' || child.type === 'Combinator') {
block = new Block(child);
blocks.push(block);
} else {
block.add(child);
}
});
return blocks;
}