diff --git a/package.json b/package.json index 84136453e7..2a0308d430 100644 --- a/package.json +++ b/package.json @@ -60,12 +60,12 @@ "eslint": "^3.12.2", "eslint-plugin-html": "^3.0.0", "eslint-plugin-import": "^2.2.0", - "estree-walker": "^0.3.0", + "estree-walker": "^0.5.0", "fuzzyset.js": "0.0.1", "glob": "^7.1.1", "jsdom": "^9.9.1", "locate-character": "^2.0.0", - "magic-string": "^0.21.1", + "magic-string": "^0.22.1", "mocha": "^3.2.0", "node-resolve": "^1.3.3", "nyc": "^10.0.0", diff --git a/src/generators/Selector.ts b/src/css/Selector.ts similarity index 50% rename from src/generators/Selector.ts rename to src/css/Selector.ts index e7a23489c1..0ae69d6c1c 100644 --- a/src/generators/Selector.ts +++ b/src/css/Selector.ts @@ -1,38 +1,37 @@ import MagicString from 'magic-string'; -import { groupSelectors, isGlobalSelector, walkRules } from '../utils/css'; +import { Validator } from '../validate/index'; import { Node } from '../interfaces'; +interface Block { + global: boolean; + combinator: Node; + selectors: Node[] +} + export default class Selector { node: Node; - blocks: any; // TODO - parts: Node[]; + blocks: Block[]; + localBlocks: Block[]; used: boolean; constructor(node: Node) { this.node = node; - this.blocks = groupSelectors(this.node); + this.blocks = groupSelectors(node); // take trailing :global(...) selectors out of consideration - let i = node.children.length; - while (i > 2) { - const last = node.children[i-1]; - const penultimate = node.children[i-2]; - - if (last.type === 'PseudoClassSelector' && last.name === 'global') { - i -= 2; - } else { - break; - } + let i = this.blocks.length; + while (i > 0) { + if (!this.blocks[i - 1].global) break; + i -= 1; } - this.parts = node.children.slice(0, i); - + this.localBlocks = this.blocks.slice(0, i); this.used = this.blocks[0].global; } apply(node: Node, stack: Node[]) { - const applies = selectorAppliesTo(this.parts, node, stack.slice()); + const applies = selectorAppliesTo(this.localBlocks.slice(), node, stack.slice()); if (applies) { this.used = true; @@ -45,7 +44,7 @@ export default class Selector { } transform(code: MagicString, attr: string) { - function encapsulateBlock(block) { + function encapsulateBlock(block: Block) { let i = block.selectors.length; while (i--) { const selector = block.selectors[i]; @@ -72,77 +71,102 @@ export default class Selector { } }); } + + validate(validator: Validator) { + this.blocks.forEach((block) => { + let i = block.selectors.length; + while (i-- > 1) { + const selector = block.selectors[i]; + if (selector.type === 'PseudoClassSelector' && selector.name === 'global') { + validator.error(`:global(...) must be the first element in a compound selector`, selector.start); + } + } + }); + + 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) { + validator.error(`:global(...) can be at the start or end of a selector sequence, but not in the middle`, this.blocks[i].selectors[0].start); + } + } + } } function isDescendantSelector(selector: Node) { return selector.type === 'WhiteSpace' || selector.type === 'Combinator'; } -function selectorAppliesTo(parts: Node[], node: Node, stack: Node[]): boolean { - let i = parts.length; +function selectorAppliesTo(blocks: Block[], node: Node, stack: Node[]): boolean { + const block = blocks.pop(); + if (!block) return false; + + if (!node) { + return blocks.every(block => block.global); + } + + let i = block.selectors.length; let j = stack.length; while (i--) { - if (!node) { - return parts.every((part: Node) => { - return part.type === 'Combinator' || (part.type === 'PseudoClassSelector' && part.name === 'global'); - }); - } + const selector = block.selectors[i]; - const part = parts[i]; - - if (part.type === 'PseudoClassSelector' && part.name === 'global') { + 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 (part.type === 'PseudoClassSelector' || part.type === 'PseudoElementSelector') { + if (selector.type === 'PseudoClassSelector' || selector.type === 'PseudoElementSelector') { continue; } - if (part.type === 'ClassSelector') { - if (!attributeMatches(node, 'class', part.name, '~=', false)) return false; + if (selector.type === 'ClassSelector') { + if (!attributeMatches(node, 'class', selector.name, '~=', false)) return false; } - else if (part.type === 'IdSelector') { - if (!attributeMatches(node, 'id', part.name, '=', false)) return false; + else if (selector.type === 'IdSelector') { + if (!attributeMatches(node, 'id', selector.name, '=', false)) return false; } - else if (part.type === 'AttributeSelector') { - if (!attributeMatches(node, part.name.name, part.value && unquote(part.value.value), part.operator, part.flags)) return false; + else if (selector.type === 'AttributeSelector') { + if (!attributeMatches(node, selector.name.name, selector.value && unquote(selector.value.value), selector.operator, selector.flags)) return false; } - else if (part.type === 'TypeSelector') { - if (part.name === '*') return true; - if (node.name !== part.name) return false; + else if (selector.type === 'TypeSelector') { + if (node.name !== selector.name && selector.name !== '*') return false; } - else if (part.type === 'WhiteSpace') { - parts = parts.slice(0, i); + else { + // bail. TODO figure out what these could be + return true; + } + } + if (block.combinator) { + if (block.combinator.type === 'WhiteSpace') { while (stack.length) { - if (selectorAppliesTo(parts, stack.pop(), stack)) { + if (selectorAppliesTo(blocks.slice(), stack.pop(), stack)) { return true; } } return false; + } else if (block.combinator.name === '>') { + return selectorAppliesTo(blocks, stack.pop(), stack); } - else if (part.type === 'Combinator') { - if (part.name === '>') { - return selectorAppliesTo(parts.slice(0, i), stack.pop(), stack); - } - - // TODO other combinators - return true; - } - - else { - // bail. TODO figure out what these could be - return true; - } + // TODO other combinators + return true; } return true; @@ -177,4 +201,32 @@ function unquote(str: string) { if (str[0] === str[str.length - 1] && str[0] === "'" || str[0] === '"') { return str.slice(1, str.length - 1); } +} + +function groupSelectors(selector: Node) { + let block: Block = { + global: selector.children[0].type === 'PseudoClassSelector' && selector.children[0].name === 'global', + selectors: [], + combinator: null + }; + + const blocks = [block]; + + selector.children.forEach((child: Node, i: number) => { + if (child.type === 'WhiteSpace' || child.type === 'Combinator') { + const next = selector.children[i + 1]; + + block = { + global: next.type === 'PseudoClassSelector' && next.name === 'global', + selectors: [], + combinator: child + }; + + blocks.push(block); + } else { + block.selectors.push(child); + } + }); + + return blocks; } \ No newline at end of file diff --git a/src/css/Stylesheet.ts b/src/css/Stylesheet.ts new file mode 100644 index 0000000000..ac9d5cee5a --- /dev/null +++ b/src/css/Stylesheet.ts @@ -0,0 +1,233 @@ +import MagicString from 'magic-string'; +import { walk } from 'estree-walker'; +import { getLocator } from 'locate-character'; +import Selector from './Selector'; +import getCodeFrame from '../utils/getCodeFrame'; +import { Validator } from '../validate/index'; +import { Node, Parsed, Warning } from '../interfaces'; + +class Rule { + selectors: Selector[]; + declarations: Node[]; + + constructor(node: Node) { + this.selectors = node.selector.children.map((node: Node) => new Selector(node)); + this.declarations = node.block.children; + } + + apply(node: Node, stack: Node[]) { + this.selectors.forEach(selector => selector.apply(node, stack)); // TODO move the logic in here? + } + + transform(code: MagicString, id: string, keyframes: Map, cascade: boolean) { + const attr = `[${id}]`; + + if (cascade) { + this.selectors.forEach(selector => { + // TODO disable cascading (without :global(...)) in v2 + const { start, end, children } = selector.node; + + const css = code.original; + const selectorString = css.slice(start, end); + + const firstToken = children[0]; + + let transformed; + + if (firstToken.type === 'TypeSelector') { + const insert = firstToken.end; + const head = firstToken.name === '*' ? '' : css.slice(start, insert); + const tail = css.slice(insert, end); + + transformed = `${head}${attr}${tail}, ${attr} ${selectorString}`; + } else { + transformed = `${attr}${selectorString}, ${attr} ${selectorString}`; + } + + code.overwrite(start, end, transformed); + }); + } else { + this.selectors.forEach(selector => selector.transform(code, attr)); + } + + this.declarations.forEach((declaration: Node) => { + const property = declaration.property.toLowerCase(); + if (property === 'animation' || property === 'animation-name') { + declaration.value.children.forEach((block: Node) => { + if (block.type === 'Identifier') { + const name = block.name; + if (keyframes.has(name)) { + code.overwrite(block.start, block.end, keyframes.get(name)); + } + } + }); + } + }); + } +} + +class Atrule { + node: Node; + + constructor(node: Node) { + this.node = node; + } + + transform(code: MagicString, id: string, keyframes: Map) { + if (this.node.name !== 'keyframes') return; + + this.node.expression.children.forEach((expression: Node) => { + if (expression.type === 'Identifier') { + if (expression.name.startsWith('-global-')) { + code.remove(expression.start, expression.start + 8); + } else { + const newName = `${id}-${expression.name}`; + code.overwrite(expression.start, expression.end, newName); + keyframes.set(expression.name, newName); + } + } + }); + } +} + +const keys = {}; + +export default class Stylesheet { + source: string; + parsed: Parsed; + cascade: boolean; + filename: string; + + hasStyles: boolean; + id: string; + + nodes: (Rule|Atrule)[]; + rules: Rule[]; + atrules: Atrule[]; + + constructor(source: string, parsed: Parsed, filename: string, cascade: boolean) { + this.source = source; + this.parsed = parsed; + this.cascade = cascade; + this.filename = filename; + + this.id = `svelte-${parsed.hash}`; + + this.nodes = []; + this.rules = []; + this.atrules = []; + + if (parsed.css && parsed.css.children.length) { + this.hasStyles = true; + + const stack: Atrule[] = []; + let currentAtrule: Atrule = null; + + walk(this.parsed.css, { + enter: (node: Node) => { + if (node.type === 'Atrule') { + const atrule = currentAtrule = new Atrule(node); + stack.push(atrule); + + this.nodes.push(atrule); + this.atrules.push(atrule); + } + + if (node.type === 'Rule' && (!currentAtrule || /(media|supports|document)/.test(currentAtrule.node.name))) { + const rule = new Rule(node); + this.nodes.push(rule); + this.rules.push(rule); + } + }, + + leave: (node: Node) => { + if (node.type === 'Atrule') { + stack.pop(); + currentAtrule = stack[stack.length - 1]; + } + } + }); + } else { + this.hasStyles = false; + } + } + + apply(node: Node, stack: Node[]) { + if (!this.hasStyles) return; + + if (this.cascade) { + if (stack.length === 0) node._needsCssAttribute = true; + return; + } + + for (let i = 0; i < this.rules.length; i += 1) { + const rule = this.rules[i]; + rule.apply(node, stack); + } + } + + render(cssOutputFilename: string) { + if (!this.hasStyles) { + return { css: null, cssMap: null }; + } + + const code = new MagicString(this.source); + code.remove(0, this.parsed.css.start + 7); + code.remove(this.parsed.css.end - 8, this.source.length); + + const keyframes = new Map(); + this.atrules.forEach((atrule: Atrule) => { + atrule.transform(code, this.id, keyframes); + }); + + this.rules.forEach((rule: Rule) => { + rule.transform(code, this.id, keyframes, this.cascade); + }); + + return { + css: code.toString(), + cssMap: code.generateMap({ + includeContent: true, + source: this.filename, + file: cssOutputFilename + }) + }; + } + + validate(validator: Validator) { + this.rules.forEach(rule => { + rule.selectors.forEach(selector => { + selector.validate(validator); + }); + }); + } + + warnOnUnusedSelectors(onwarn: (warning: Warning) => void) { + if (this.cascade) return; + + let locator; + + this.rules.forEach((rule: Rule) => { + rule.selectors.forEach(selector => { + if (!selector.used) { + const pos = selector.node.start; + + if (!locator) locator = getLocator(this.source); + const { line, column } = locator(pos); + + const frame = getCodeFrame(this.source, line, column); + const message = `Unused CSS selector`; + + onwarn({ + message, + frame, + loc: { line: line + 1, column }, + pos, + filename: this.filename, + toString: () => `${message} (${line + 1}:${column})\n${frame}`, + }); + } + }); + }); + } +} \ No newline at end of file diff --git a/src/generators/Generator.ts b/src/generators/Generator.ts index d1cee03e1a..e20deb75bb 100644 --- a/src/generators/Generator.ts +++ b/src/generators/Generator.ts @@ -10,13 +10,11 @@ import namespaces from '../utils/namespaces'; import { removeNode, removeObjectKey } from '../utils/removeNode'; import getIntro from './shared/utils/getIntro'; import getOutro from './shared/utils/getOutro'; -import processCss from './shared/processCss'; import annotateWithScopes from '../utils/annotateWithScopes'; import clone from '../utils/clone'; import DomBlock from './dom/Block'; import SsrBlock from './server-side-rendering/Block'; -import { walkRules } from '../utils/css'; -import Selector from './Selector'; +import Stylesheet from '../css/Stylesheet'; import { Node, Parsed, CompileOptions } from '../interfaces'; const test = typeof global !== 'undefined' && global.__svelte_test; @@ -39,12 +37,9 @@ export default class Generator { bindingGroups: string[]; indirectDependencies: Map>; expectedProperties: Set; - cascade: boolean; - css: string; - cssId: string; usesRefs: boolean; - selectors: Selector[]; + stylesheet: Stylesheet; importedNames: Set; aliases: Map; @@ -54,6 +49,7 @@ export default class Generator { parsed: Parsed, source: string, name: string, + stylesheet: Stylesheet, options: CompileOptions ) { this.ast = clone(parsed); @@ -80,21 +76,10 @@ export default class Generator { this.usesRefs = false; // styles - this.cascade = options.cascade !== false; // TODO remove this option in v2 - this.cssId = parsed.css ? `svelte-${parsed.hash}` : ''; - this.selectors = []; - - if (parsed.css) { - walkRules(parsed.css.children, node => { - node.selector.children.forEach((child: Node) => { - this.selectors.push(new Selector(child)); - }); - }); + this.stylesheet = stylesheet; - this.css = processCss(this, this.code, this.cascade); - } else { - this.css = null; - } + // TODO this is legacy — just to get the tests to pass during the transition + this.css = this.stylesheet.render(options.cssOutputFilename).css; // allow compiler to deconflict user's `import { get } from 'whatever'` and // Svelte's builtin `import { get, ... } from 'svelte/shared.ts'`; @@ -231,20 +216,6 @@ export default class Generator { }; } - applyCss(node: Node, stack: Node[]) { - if (!this.cssId) return; - - if (this.cascade) { - if (stack.length === 0) node._needsCssAttribute = true; - return; - } - - for (let i = 0; i < this.selectors.length; i += 1) { - const selector = this.selectors[i]; - selector.apply(node, stack); - } - } - findDependencies( contextDependencies: Map, indexes: Map, @@ -292,7 +263,7 @@ export default class Generator { return (expression._dependencies = dependencies); } - generate(result, options, { name, format }) { + generate(result, options: CompileOptions, { name, format }) { if (this.imports.length) { const statements: string[] = []; @@ -382,6 +353,8 @@ export default class Generator { addString(finalChunk); addString('\n\n' + getOutro(format, name, options, this.imports)); + const { css, cssMap } = this.stylesheet.render(options.cssOutputFilename); + return { ast: this.ast, code: compiled.toString(), @@ -389,7 +362,8 @@ export default class Generator { includeContent: true, file: options.outputFilename, }), - css: this.css, + css, + cssMap }; } @@ -624,31 +598,4 @@ export default class Generator { this.namespace = namespace; this.templateProperties = templateProperties; } - - warnOnUnusedSelectors() { - if (this.cascade) return; - - let locator; - - this.selectors.forEach((selector: Selector) => { - if (!selector.used) { - const pos = selector.node.start; - - if (!locator) locator = getLocator(this.source); - const { line, column } = locator(pos); - - const frame = getCodeFrame(this.source, line, column); - const message = `Unused CSS selector`; - - this.options.onwarn({ - message, - frame, - loc: { line: line + 1, column }, - pos, - filename: this.options.filename, - toString: () => `${message} (${line + 1}:${column})\n${frame}`, - }); - } - }); - } } diff --git a/src/generators/dom/index.ts b/src/generators/dom/index.ts index 393c83fe1f..55c6599cd7 100644 --- a/src/generators/dom/index.ts +++ b/src/generators/dom/index.ts @@ -9,6 +9,7 @@ import CodeBuilder from '../../utils/CodeBuilder'; import visit from './visit'; import shared from './shared'; import Generator from '../Generator'; +import Stylesheet from '../../css/Stylesheet'; import preprocess from './preprocess'; import Block from './Block'; import { Parsed, CompileOptions, Node } from '../../interfaces'; @@ -28,9 +29,10 @@ export class DomGenerator extends Generator { parsed: Parsed, source: string, name: string, + stylesheet: Stylesheet, options: CompileOptions ) { - super(parsed, source, name, options); + super(parsed, source, name, stylesheet, options); this.blocks = []; this.readonly = new Set(); @@ -45,11 +47,12 @@ export class DomGenerator extends Generator { export default function dom( parsed: Parsed, source: string, + stylesheet: Stylesheet, options: CompileOptions ) { const format = options.format || 'es'; - const generator = new DomGenerator(parsed, source, options.name || 'SvelteComponent', options); + const generator = new DomGenerator(parsed, source, options.name || 'SvelteComponent', stylesheet, options); const { computations, @@ -61,7 +64,7 @@ export default function dom( const { block, state } = preprocess(generator, namespace, parsed.html); - generator.warnOnUnusedSelectors(); + generator.stylesheet.warnOnUnusedSelectors(options.onwarn); parsed.html.children.forEach((node: Node) => { visit(generator, block, state, node, []); @@ -131,12 +134,18 @@ export default function dom( builder.addBlock(`[✂${parsed.js.content.start}-${parsed.js.content.end}✂]`); } - if (generator.css && options.css !== false) { + if (generator.stylesheet.hasStyles && options.css !== false) { + const { css, cssMap } = generator.stylesheet.render(options.filename); + + const textContent = options.dev ? + `${css}\n/*# sourceMappingURL=${cssMap.toUrl()} */` : + css; + builder.addBlock(deindent` function @add_css () { var style = @createElement( 'style' ); - style.id = '${generator.cssId}-style'; - style.textContent = ${stringify(generator.css)}; + style.id = '${generator.stylesheet.id}-style'; + style.textContent = ${JSON.stringify(textContent)}; @appendNode( style, document.head ); } `); @@ -195,9 +204,9 @@ export default function dom( this._yield = options._yield; this._torndown = false; - ${generator.css && + ${generator.stylesheet.hasStyles && options.css !== false && - `if ( !document.getElementById( '${generator.cssId}-style' ) ) @add_css();`} + `if ( !document.getElementById( '${generator.stylesheet.id}-style' ) ) @add_css();`} ${generator.hasComponents && `this._oncreate = [];`} ${generator.hasComplexBindings && `this._bindings = [];`} ${generator.hasIntroTransitions && `this._postcreate = [];`} diff --git a/src/generators/dom/preprocess.ts b/src/generators/dom/preprocess.ts index e4ea6f8f9f..b8a569e778 100644 --- a/src/generators/dom/preprocess.ts +++ b/src/generators/dom/preprocess.ts @@ -330,7 +330,7 @@ const preprocessors = { allUsedContexts: [], }); - generator.applyCss(node, elementStack); + generator.stylesheet.apply(node, elementStack); } if (node.children.length) { diff --git a/src/generators/dom/visitors/Element/Element.ts b/src/generators/dom/visitors/Element/Element.ts index bb7de853bb..9a8e600583 100644 --- a/src/generators/dom/visitors/Element/Element.ts +++ b/src/generators/dom/visitors/Element/Element.ts @@ -84,7 +84,7 @@ export default function visitElement( // TODO add a helper for this, rather than repeating it if (node._needsCssAttribute) { block.builders.hydrate.addLine( - `@setAttribute( ${name}, '${generator.cssId}', '' );` + `@setAttribute( ${name}, '${generator.stylesheet.id}', '' );` ); } diff --git a/src/generators/server-side-rendering/index.ts b/src/generators/server-side-rendering/index.ts index ff18b06878..53518e32ee 100644 --- a/src/generators/server-side-rendering/index.ts +++ b/src/generators/server-side-rendering/index.ts @@ -1,5 +1,6 @@ import deindent from '../../utils/deindent'; import Generator from '../Generator'; +import Stylesheet from '../../css/Stylesheet'; import Block from './Block'; import preprocess from './preprocess'; import visit from './visit'; @@ -15,9 +16,10 @@ export class SsrGenerator extends Generator { parsed: Parsed, source: string, name: string, + stylesheet: Stylesheet, options: CompileOptions ) { - super(parsed, source, name, options); + super(parsed, source, name, stylesheet, options); this.bindings = []; this.renderCode = ''; this.elementDepth = 0; @@ -27,7 +29,7 @@ export class SsrGenerator extends Generator { preprocess(this, parsed.html); - this.warnOnUnusedSelectors(); + this.stylesheet.warnOnUnusedSelectors(options.onwarn); if (templateProperties.oncreate) removeNode( @@ -63,11 +65,12 @@ export class SsrGenerator extends Generator { export default function ssr( parsed: Parsed, source: string, + stylesheet: Stylesheet, options: CompileOptions ) { const format = options.format || 'cjs'; - const generator = new SsrGenerator(parsed, source, options.name || 'SvelteComponent', options); + const generator = new SsrGenerator(parsed, source, options.name || 'SvelteComponent', stylesheet, options); const { computations, name, hasJs, templateProperties } = generator; @@ -83,6 +86,8 @@ export default function ssr( visit(generator, mainBlock, node); }); + const { css, cssMap } = generator.stylesheet.render(options.filename); + const result = deindent` ${hasJs && `[✂${parsed.js.content.start}-${parsed.js.content.end}✂]`} @@ -124,12 +129,12 @@ export default function ssr( ${name}.renderCss = function () { var components = []; - ${generator.css && + ${generator.stylesheet.hasStyles && deindent` components.push({ filename: ${name}.filename, - css: ${JSON.stringify(generator.css)}, - map: null // TODO + css: ${JSON.stringify(css)}, + map: ${JSON.stringify(cssMap)} }); `} diff --git a/src/generators/server-side-rendering/preprocess.ts b/src/generators/server-side-rendering/preprocess.ts index c82fda9e9b..1f6effc5f7 100644 --- a/src/generators/server-side-rendering/preprocess.ts +++ b/src/generators/server-side-rendering/preprocess.ts @@ -61,7 +61,7 @@ const preprocessors = { generator.components.has(node.name) || node.name === ':Self'; if (!isComponent) { - generator.applyCss(node, elementStack); + generator.stylesheet.apply(node, elementStack); } if (node.children.length) { diff --git a/src/generators/server-side-rendering/visitors/Element.ts b/src/generators/server-side-rendering/visitors/Element.ts index 765e16ec8a..059e8bced2 100644 --- a/src/generators/server-side-rendering/visitors/Element.ts +++ b/src/generators/server-side-rendering/visitors/Element.ts @@ -57,7 +57,7 @@ export default function visitElement( }); if (node._needsCssAttribute) { - openingTag += ` ${generator.cssId}`; + openingTag += ` ${generator.stylesheet.id}`; } openingTag += '>'; diff --git a/src/generators/shared/processCss.ts b/src/generators/shared/processCss.ts deleted file mode 100644 index 5caec55239..0000000000 --- a/src/generators/shared/processCss.ts +++ /dev/null @@ -1,104 +0,0 @@ -import MagicString from 'magic-string'; -import { groupSelectors, isGlobalSelector, walkRules } from '../../utils/css'; -import Generator from '../Generator'; -import { Node } from '../../interfaces'; - -const commentsPattern = /\/\*[\s\S]*?\*\//g; - -export default function processCss( - generator: Generator, - code: MagicString, - cascade: boolean -) { - const css = generator.parsed.css.content.styles; - const offset = generator.parsed.css.content.start; - - const attr = `[svelte-${generator.parsed.hash}]`; - - const keyframes = new Map(); - - function walkKeyframes(node: Node) { - if (node.type === 'Atrule' && node.name.toLowerCase() === 'keyframes') { - node.expression.children.forEach((expression: Node) => { - if (expression.type === 'Identifier') { - if (expression.name.startsWith('-global-')) { - code.remove(expression.start, expression.start + 8); - } else { - const newName = `svelte-${generator.parsed.hash}-${expression.name}`; - code.overwrite(expression.start, expression.end, newName); - keyframes.set(expression.name, newName); - } - } - }); - } else if (node.children) { - node.children.forEach(walkKeyframes); - } else if (node.block) { - walkKeyframes(node.block); - } - } - - generator.parsed.css.children.forEach(walkKeyframes); - - function transform(rule: Node) { - rule.selector.children.forEach((selector: Node) => { - if (cascade) { - // TODO disable cascading (without :global(...)) in v2 - const start = selector.start - offset; - const end = selector.end - offset; - - const selectorString = css.slice(start, end); - - const firstToken = selector.children[0]; - - let transformed; - - if (firstToken.type === 'TypeSelector') { - const insert = firstToken.end - offset; - const head = firstToken.name === '*' ? css.slice(firstToken.end - offset, insert) : css.slice(start, insert); - const tail = css.slice(insert, end); - - transformed = `${head}${attr}${tail}, ${attr} ${selectorString}`; - } else { - transformed = `${attr}${selectorString}, ${attr} ${selectorString}`; - } - - code.overwrite(selector.start, selector.end, transformed); - } - }); - - rule.block.children.forEach((block: Node) => { - if (block.type === 'Declaration') { - const property = block.property.toLowerCase(); - if (property === 'animation' || property === 'animation-name') { - block.value.children.forEach((block: Node) => { - if (block.type === 'Identifier') { - const name = block.name; - if (keyframes.has(name)) { - code.overwrite(block.start, block.end, keyframes.get(name)); - } - } - }); - } - } - }); - } - - walkRules(generator.parsed.css.children, transform); - - if (!cascade) { - generator.selectors.forEach(selector => { - selector.transform(code, attr); - }); - } - - // remove comments. TODO would be nice if this was exposed in css-tree - let match; - while ((match = commentsPattern.exec(css))) { - const start = match.index + offset; - const end = start + match[0].length; - - code.remove(start, end); - } - - return code.slice(generator.parsed.css.content.start, generator.parsed.css.content.end); -} diff --git a/src/index.ts b/src/index.ts index 5c9d98992f..c9c4d23a1e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import generate from './generators/dom/index'; import generateSSR from './generators/server-side-rendering/index'; import { assign } from './shared/index.js'; import { version } from '../package.json'; +import Stylesheet from './css/Stylesheet'; import { Parsed, CompileOptions, Warning } from './interfaces'; function normalizeOptions(options: CompileOptions): CompileOptions { @@ -44,11 +45,13 @@ export function compile(source: string, _options: CompileOptions) { return; } - validate(parsed, source, options); + const stylesheet = new Stylesheet(source, parsed, options.filename, options.cascade !== false); + + validate(parsed, source, stylesheet, options); const compiler = options.generate === 'ssr' ? generateSSR : generate; - return compiler(parsed, source, options); + return compiler(parsed, source, stylesheet, options); } export function create(source: string, _options: CompileOptions = {}) { diff --git a/src/interfaces.ts b/src/interfaces.ts index 78fc8730b9..668c9816f8 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -40,6 +40,9 @@ export interface CompileOptions { filename?: string; generate?: string; + outputFilename?: string; + cssOutputFilename?: string; + dev?: boolean; shared?: boolean | string; cascade?: boolean; diff --git a/src/utils/css.ts b/src/utils/css.ts deleted file mode 100644 index 1e00d657e4..0000000000 --- a/src/utils/css.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Node } from '../interfaces'; - -export function isGlobalSelector(block: Node[]) { - return block[0].type === 'PseudoClassSelector' && block[0].name === 'global'; -} - -export function groupSelectors(selector: Node) { - let block = { - global: selector.children[0].type === 'PseudoClassSelector' && selector.children[0].name === 'global', - selectors: [], - combinator: null - }; - - const blocks = [block]; - - selector.children.forEach((child: Node, i: number) => { - if (child.type === 'WhiteSpace' || child.type === 'Combinator') { - const next = selector.children[i + 1]; - - block = { - global: next.type === 'PseudoClassSelector' && next.name === 'global', - selectors: [], - combinator: child - }; - - blocks.push(block); - } else { - block.selectors.push(child); - } - }); - - return blocks; -} - -export function walkRules(nodes: Node[], callback: (node: Node) => void) { - nodes.forEach((node: Node) => { - if (node.type === 'Rule') { - callback(node); - } else if (node.type === 'Atrule') { - if (node.name === 'media' || node.name === 'supports' || node.name === 'document') { - walkRules(node.block.children, callback); - } - } - }); -} \ No newline at end of file diff --git a/src/validate/css/index.ts b/src/validate/css/index.ts deleted file mode 100644 index 57c13a7716..0000000000 --- a/src/validate/css/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { groupSelectors, isGlobalSelector, walkRules } from '../../utils/css'; -import { Validator } from '../index'; -import { Node } from '../../interfaces'; - -export default function validateCss(validator: Validator, css: Node) { - walkRules(css.children, rule => { - rule.selector.children.forEach(validateSelector); - }); - - function validateSelector(selector: Node) { - const blocks = groupSelectors(selector); - - blocks.forEach((block) => { - let i = block.selectors.length; - while (i-- > 1) { - const part = block.selectors[i]; - if (part.type === 'PseudoClassSelector' && part.name === 'global') { - validator.error(`:global(...) must be the first element in a compound selector`, part.start); - } - } - }); - - let start = 0; - let end = blocks.length; - - for (; start < end; start += 1) { - if (!blocks[start].global) break; - } - - for (; end > start; end -= 1) { - if (!blocks[end - 1].global) break; - } - - for (let i = start; i < end; i += 1) { - if (blocks[i].global) { - validator.error(`:global(...) can be at the start or end of a selector sequence, but not in the middle`, blocks[i].selectors[0].start); - } - } - } -} \ No newline at end of file diff --git a/src/validate/index.ts b/src/validate/index.ts index 62cb9e614d..d29a054c9a 100644 --- a/src/validate/index.ts +++ b/src/validate/index.ts @@ -1,9 +1,9 @@ import validateJs from './js/index'; -import validateCss from './css/index'; import validateHtml from './html/index'; import { getLocator, Location } from 'locate-character'; import getCodeFrame from '../utils/getCodeFrame'; import CompileError from '../utils/CompileError'; +import Stylesheet from '../css/Stylesheet'; import { Node, Parsed, CompileOptions, Warning } from '../interfaces'; class ValidationError extends CompileError { @@ -73,6 +73,7 @@ export class Validator { export default function validate( parsed: Parsed, source: string, + stylesheet: Stylesheet, options: CompileOptions ) { const { onwarn, onerror, name, filename } = options; @@ -103,7 +104,7 @@ export default function validate( } if (parsed.css) { - validateCss(validator, parsed.css); + stylesheet.validate(validator); } if (parsed.html) { diff --git a/test/css/index.js b/test/css/index.js index 6c56238ce2..93f7c218ea 100644 --- a/test/css/index.js +++ b/test/css/index.js @@ -25,12 +25,13 @@ describe("css", () => { // add .solo to a sample directory name to only run that test const solo = /\.solo/.test(dir); + const skip = /\.skip/.test(dir); if (solo && process.env.CI) { throw new Error("Forgot to remove `solo: true` from test"); } - (solo ? it.only : it)(dir, () => { + (solo ? it.only : skip ? it.skip : it)(dir, () => { const config = tryRequire(`./samples/${dir}/_config.js`) || {}; const input = fs .readFileSync(`test/css/samples/${dir}/input.html`, "utf-8") diff --git a/test/sourcemaps/index.js b/test/sourcemaps/index.js index 2209ad0f7d..91612e6aa3 100644 --- a/test/sourcemaps/index.js +++ b/test/sourcemaps/index.js @@ -21,34 +21,50 @@ describe("sourcemaps", () => { `test/sourcemaps/samples/${dir}/input.html` ); const outputFilename = path.resolve( - `test/sourcemaps/samples/${dir}/output.js` + `test/sourcemaps/samples/${dir}/output` ); const input = fs.readFileSync(filename, "utf-8").replace(/\s+$/, ""); - const { code, map } = svelte.compile(input, { + const { code, map, css, cssMap } = svelte.compile(input, { filename, - outputFilename + outputFilename: `${outputFilename}.js`, + cssOutputFilename: `${outputFilename}.css` }); fs.writeFileSync( - outputFilename, + `${outputFilename}.js`, `${code}\n//# sourceMappingURL=output.js.map` ); fs.writeFileSync( - `${outputFilename}.map`, + `${outputFilename}.js.map`, JSON.stringify(map, null, " ") ); + if (css) { + fs.writeFileSync( + `${outputFilename}.css`, + `${css}\n/*# sourceMappingURL=output.css.map */` + ); + fs.writeFileSync( + `${outputFilename}.css.map`, + JSON.stringify(cssMap, null, " ") + ); + } + assert.deepEqual(map.sources, ["input.html"]); + if (cssMap) assert.deepEqual(cssMap.sources, ["input.html"]); const { test } = require(`./samples/${dir}/test.js`); - const smc = new SourceMapConsumer(map); - const locateInSource = getLocator(input); + + const smc = new SourceMapConsumer(map); const locateInGenerated = getLocator(code); - test({ assert, code, map, smc, locateInSource, locateInGenerated }); + const smcCss = cssMap && new SourceMapConsumer(cssMap); + const locateInGeneratedCss = getLocator(css || ''); + + test({ assert, code, map, smc, smcCss, locateInSource, locateInGenerated, locateInGeneratedCss }); }); }); }); diff --git a/test/sourcemaps/samples/css/input.html b/test/sourcemaps/samples/css/input.html new file mode 100644 index 0000000000..ad0845d15b --- /dev/null +++ b/test/sourcemaps/samples/css/input.html @@ -0,0 +1,7 @@ +

red

+ + \ No newline at end of file diff --git a/test/sourcemaps/samples/css/output.css b/test/sourcemaps/samples/css/output.css new file mode 100644 index 0000000000..9f3e676b54 --- /dev/null +++ b/test/sourcemaps/samples/css/output.css @@ -0,0 +1,6 @@ + + [svelte-2772200924].foo, [svelte-2772200924] .foo { + color: red; + } + +/*# sourceMappingURL=output.css.map */ \ No newline at end of file diff --git a/test/sourcemaps/samples/css/output.css.map b/test/sourcemaps/samples/css/output.css.map new file mode 100644 index 0000000000..c77b6d62e7 --- /dev/null +++ b/test/sourcemaps/samples/css/output.css.map @@ -0,0 +1,12 @@ +{ + "version": 3, + "file": "output.css", + "sources": [ + "input.html" + ], + "sourcesContent": [ + "

red

\n\n" + ], + "names": [], + "mappings": "AAEO;CACN,iDAAI;;;AAGL" +} \ No newline at end of file diff --git a/test/sourcemaps/samples/css/test.js b/test/sourcemaps/samples/css/test.js new file mode 100644 index 0000000000..0c9d7e9b6e --- /dev/null +++ b/test/sourcemaps/samples/css/test.js @@ -0,0 +1,17 @@ +export function test ({ assert, smcCss, locateInSource, locateInGeneratedCss }) { + const expected = locateInSource( '.foo' ); + + const loc = locateInGeneratedCss( '.foo' ); + + const actual = smcCss.originalPositionFor({ + line: loc.line + 1, + column: loc.column + }); + + assert.deepEqual( actual, { + source: 'input.html', + name: null, + line: expected.line + 1, + column: expected.column + }); +} diff --git a/test/validator/index.js b/test/validator/index.js index 5a63958c9c..0782997573 100644 --- a/test/validator/index.js +++ b/test/validator/index.js @@ -18,20 +18,9 @@ describe("validate", () => { const input = fs.readFileSync(filename, "utf-8").replace(/\s+$/, ""); try { - const parsed = svelte.parse(input); - - const errors = []; const warnings = []; - svelte.validate(parsed, input, { - onerror(error) { - errors.push({ - message: error.message, - pos: error.pos, - loc: error.loc - }); - }, - + svelte.compile(input, { onwarn(warning) { warnings.push({ message: warning.message, @@ -41,16 +30,11 @@ describe("validate", () => { } }); - const expectedErrors = - tryToLoadJson(`test/validator/samples/${dir}/errors.json`) || []; const expectedWarnings = tryToLoadJson(`test/validator/samples/${dir}/warnings.json`) || []; - assert.deepEqual(errors, expectedErrors); assert.deepEqual(warnings, expectedWarnings); } catch (err) { - if (err.name !== "ParseError") throw err; - try { const expected = require(`./samples/${dir}/errors.json`)[0]; diff --git a/yarn.lock b/yarn.lock index a3b9fbe5a4..45c20cdf10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1224,6 +1224,10 @@ estree-walker@^0.3.0: version "0.3.1" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.3.1.tgz#e6b1a51cf7292524e7237c312e5fe6660c1ce1aa" +estree-walker@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.5.0.tgz#aae3b57c42deb8010e349c892462f0e71c5dd1aa" + esutils@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" @@ -1972,9 +1976,9 @@ magic-string@^0.19.0, magic-string@~0.19.0: dependencies: vlq "^0.2.1" -magic-string@^0.21.1: - version "0.21.3" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.21.3.tgz#87e201009ebfde6f46dc5757305a70af71e31624" +magic-string@^0.22.1: + version "0.22.1" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.22.1.tgz#a1bda64dfd4ae6c63797a45a67ee473b1f8d0e0f" dependencies: vlq "^0.2.1"