From 39bcd477ffa5d9eeb10fcd1032d53c2a5c5c8cfa Mon Sep 17 00:00:00 2001 From: Ryan Tsao Date: Wed, 29 Jun 2016 23:16:13 -0700 Subject: [PATCH 1/2] First pass at lexer approach --- lib/build-exports.js | 2 +- lib/csjs.js | 14 +- lib/lexer.js | 540 +++++++++++++++++++++++++++++++++++++++++++ lib/scopeify.js | 68 ------ lib/scopify.js | 72 ++++++ 5 files changed, 620 insertions(+), 76 deletions(-) create mode 100644 lib/lexer.js delete mode 100644 lib/scopeify.js create mode 100644 lib/scopify.js diff --git a/lib/build-exports.js b/lib/build-exports.js index 6914cd4..73e3ce3 100644 --- a/lib/build-exports.js +++ b/lib/build-exports.js @@ -5,7 +5,7 @@ var makeComposition = require('./composition').makeComposition; module.exports = function createExports(classes, keyframes, compositions) { var keyframesObj = Object.keys(keyframes).reduce(function(acc, key) { var val = keyframes[key]; - acc[val] = makeComposition([key], [val], true); + acc[key] = makeComposition([val], [key], true); return acc; }, {}); diff --git a/lib/csjs.js b/lib/csjs.js index db26f05..90bf7c0 100644 --- a/lib/csjs.js +++ b/lib/csjs.js @@ -3,8 +3,9 @@ var extractExtends = require('./css-extract-extends'); var isComposition = require('./composition').isComposition; var buildExports = require('./build-exports'); -var scopify = require('./scopeify'); +var scopify = require('./scopify'); var cssKey = require('./css-key'); +var lex = require('./lexer'); module.exports = function csjsHandler(strings) { // Fast path to prevent arguments deopt @@ -23,21 +24,20 @@ module.exports = function csjsHandler(strings) { return acc; }, {}); - var scoped = scopify(css, ignores); - var hashes = Object.assign({}, scoped.classes, scoped.keyframes); - var extracted = extractExtends(scoped.css, hashes); + var tokens = lex(css); + var scoped = scopify(css, tokens, ignores); var localClasses = without(scoped.classes, ignores); var localKeyframes = without(scoped.keyframes, ignores); - var compositions = extracted.compositions; + // var compositions = extracted.compositions; - var exports = buildExports(localClasses, localKeyframes, compositions); + var exports = buildExports(localClasses, localKeyframes, {}); return Object.defineProperty(exports, cssKey, { enumerable: false, configurable: false, writeable: false, - value: extracted.css + value: scoped.css }); }; diff --git a/lib/lexer.js b/lib/lexer.js new file mode 100644 index 0000000..8d33eef --- /dev/null +++ b/lib/lexer.js @@ -0,0 +1,540 @@ +module.exports = lex; + +// Supported @-rules, in roughly descending order of usage probability. +var atRules = [ + 'media', + 'keyframes', + { name: '-webkit-keyframes', type: 'keyframes', prefix: '-webkit-' }, + { name: '-moz-keyframes', type: 'keyframes', prefix: '-moz-' }, + { name: '-ms-keyframes', type: 'keyframes', prefix: '-ms-' }, + { name: '-o-keyframes', type: 'keyframes', prefix: '-o-' }, + 'font-face' +]; + +/** + * Convert a CSS string into an array of lexical tokens. + * + * @param {String} css CSS + * @returns {Array} lexical tokens + */ +function lex(css) { + + var buffer = ''; // Character accumulator + var ch; // Current character + var cursor = -1; // Current source cursor position + var depth = 0; // Current nesting depth + var state = 'before-selector'; // Current state + var stack = [state]; // State stack + var token = {}; // Current token + var tokens = []; // Token accumulator + + // -- Functions ------------------------------------------------------------ + + /** + * Return the state at the given index in the stack. + * The stack is LIFO so indexing is from the right. + * + * @param {Number} [index=0] Index to return. + * @returns {String} state + */ + function getState(index) { + return index ? stack[stack.length - 1 - index] : state; + } + + /** + * Look ahead for a string beginning from the next position. The string + * being looked for must start at the next position. + * + * @param {String} str The string to look for. + * @returns {Boolean} Whether the string was found. + */ + function isNextString(str) { + var start = cursor + 1; + return (str === css.slice(start, start + str.length)); + } + + /** + * Find the start position of a substring beginning from the next + * position. The string being looked for may begin anywhere. + * + * @param {String} str The substring to look for. + * @returns {Number|false} The position, or `false` if not found. + */ + function find(str) { + var pos = css.indexOf(str, cursor + 2); + return pos > 0 ? pos : false; + } + + /** + * Return the character at the given cursor offset. The offset is relative + * to the cursor, so negative values move backwards. + * + * @param {Number} offset Cursor offset. + * @returns {String} Character. + */ + function peek(offset) { + return css[cursor + offset]; + } + + /** + * Remove the current state from the stack and set the new current state. + * + * @returns {String} The removed state. + */ + function popState() { + var removed = stack.pop(); + state = stack[stack.length - 1]; + return removed; + } + + /** + * Set the current state and add it to the stack. + * + * @param {String} newState The new state. + * @returns {Number} The new stack length. + */ + function pushState(newState) { + state = newState; + stack.push(state); + return stack.length; + } + + /** + * Replace the current state with a new state. + * + * @param {String} newState The new state. + * @returns {String} The replaced state. + */ + function replaceState(newState) { + var previousState = state; + stack[stack.length - 1] = state = newState; + return previousState; + } + + /** + * Add the current token to the pile and reset the buffer. + */ + function addToken() { + token.end = cursor; + tokens.push(token); + buffer = ''; + token = {}; + } + + /** + * Set the current token. + * + * @param {String} type Token type. + */ + function initializeToken(type) { + token = { + type: type, + start: cursor + }; + } + + // -- Main Loop ------------------------------------------------------------ + + /* + The main loop is a state machine that reads in one character at a time, + and determines what to do based on the current state and character. + This is implemented as a series of nested `switch` statements and the + case orders have been mildly optimized based on rough probabilities + calculated by processing a small sample of real-world CSS. + + Further optimization (such as a dispatch table) shouldn't be necessary + since the total number of cases is very low. + */ + + while (ch = css[++cursor]) { + + switch (ch) { + // Space + case ' ': + switch (getState()) { + case 'selector': + case 'value': + case 'value-paren': + case 'at-group': + case 'at-value': + case 'double-string': + case 'single-string': + buffer += ch; + break; + } + break; + + // Newline or tab + case '\n': + case '\t': + case '\r': + case '\f': + switch (getState()) { + case 'value': + case 'value-paren': + case 'at-group': + case 'single-string': + case 'double-string': + case 'selector': + buffer += ch; + break; + + case 'at-value': + // Tokenize an @-rule if a semi-colon was omitted. + if ('\n' === ch) { + token.value = buffer; + addToken(); + popState(); + } + break; + } + + break; + + case ':': + switch (getState()) { + case 'name': + token.name = buffer; + buffer = ''; + + replaceState('before-value'); + break; + + case 'before-selector': + buffer += ch; + + initializeToken('selector'); + pushState('selector'); + break; + + default: + buffer += ch; + break; + } + break; + + case ';': + switch (getState()) { + case 'name': + case 'before-value': + case 'value': + // Tokenize a declaration + // if value is empty skip the declaration + if (buffer.length > 0) { + token.value = buffer, + addToken(); + } + replaceState('before-name'); + break; + + case 'value-paren': + // Insignificant semi-colon + buffer += ch; + break; + + case 'at-value': + // Tokenize an @-rule + token.value = buffer; + addToken(); + popState(); + break; + + case 'before-name': + // Extraneous semi-colon + break; + + default: + buffer += ch; + break; + } + break; + + case '{': + switch (getState()) { + case 'selector': + // If the sequence is `\{` then assume that the brace should be escaped. + if (peek(-1) === '\\') { + buffer += ch; + break; + } + + // Tokenize a selector + token.text = buffer; + addToken(); + replaceState('before-name'); + depth = depth + 1; + break; + + case 'at-group': + // Tokenize an @-group + token.name = buffer; + + // XXX: @-rules are starting to get hairy + switch (token.type) { + case 'font-face': + pushState('before-name'); + break; + + default: + pushState('before-selector'); + } + + addToken(); + depth = depth + 1; + break; + + case 'name': + case 'at-rule': + // Tokenize a declaration or an @-rule + token.name = buffer; + addToken(); + pushState('before-name'); + depth = depth + 1; + break; + + case 'double-string': + case 'single-string': + // Ignore braces in comments and strings + buffer += ch; + break; + } + + break; + + case '}': + switch (getState()) { + case 'before-name': + case 'name': + case 'before-value': + case 'value': + // If the buffer contains anything, it is a value + if (buffer) { + token.value = buffer; + } + + // If the current token has a name and a value it should be tokenized. + if (token.name && token.value) { + addToken(); + } + + // Leave the block + initializeToken('end'); + addToken(); + popState(); + + // We might need to leave again. + // XXX: What about 3 levels deep? + if ('at-group' === getState()) { + initializeToken('at-group-end'); + addToken(); + popState(); + } + + if (depth > 0) { + depth = depth - 1; + } + + break; + + case 'at-group': + case 'before-selector': + case 'selector': + // If the sequence is `\}` then assume that the brace should be escaped. + if (peek(-1) === '\\') { + buffer += ch; + break; + } + + if (depth > 0) { + // Leave block if in an at-group + if ('at-group' === getState(1)) { + initializeToken('at-group-end'); + addToken(); + } + } + + if (depth > 1) { + popState(); + } + + if (depth > 0) { + depth = depth - 1; + } + break; + + case 'double-string': + case 'single-string': + // Ignore braces in comments and strings. + buffer += ch; + break; + } + + break; + + // Strings + case '"': + case "'": + switch (getState()) { + case 'double-string': + if ('"' === ch && '\\' !== peek(-1)) { + popState(); + } + break; + + case 'single-string': + if ("'" === ch && '\\' !== peek(-1)) { + popState(); + } + break; + + case 'before-at-value': + replaceState('at-value'); + pushState('"' === ch ? 'double-string' : 'single-string'); + break; + + case 'before-value': + replaceState('value'); + pushState('"' === ch ? 'double-string' : 'single-string'); + break; + + default: + if ('\\' !== peek(-1)) { + pushState('"' === ch ? 'double-string' : 'single-string'); + } + } + + buffer += ch; + break; + + // Comments + case '/': + switch (getState()) { + case 'double-string': + case 'single-string': + // Ignore + buffer += ch; + break; + + default: + if ('*' === peek(1)) { + var pos = find('*/'); + if (pos) { + cursor = pos + 2; + } else { + cursor += css.length; + } + } + else { + buffer += ch; + } + break; + } + break; + + // Universal selector + case '*': + switch (getState()) { + + case 'before-selector': + buffer += ch; + initializeToken('selector'); + pushState('selector'); + break; + + default: + buffer += ch; + } + break; + + // @-rules + case '@': + switch (getState()) { + case 'double-string': + case 'single-string': + buffer += ch; + break; + + default: + // Iterate over the supported @-rules and attempt to tokenize one. + var tokenized = false; + var name; + var rule; + + for (var j = 0; !tokenized && j < atRules.length; ++j) { + rule = atRules[j]; + name = rule.name || rule; + + if (!isNextString(name)) { continue; } + + tokenized = true; + + initializeToken(name); + pushState(rule.state || 'at-group'); + cursor += name.length; + + if (rule.prefix) { + token.prefix = rule.prefix; + } + + if (rule.type) { + token.type = rule.type; + } + } + + if (!tokenized) { + buffer += ch; + } + break; + } + break; + + // Parentheses are tracked to disambiguate semi-colons, such as within a + // data URI. + case '(': + switch (getState()) { + case 'value': + pushState('value-paren'); + break; + } + + buffer += ch; + break; + + case ')': + switch (getState()) { + case 'value-paren': + popState(); + break; + } + + buffer += ch; + break; + + default: + switch (getState()) { + case 'before-selector': + initializeToken('selector'); + pushState('selector'); + break; + + case 'before-name': + initializeToken('property'); + replaceState('name'); + break; + + case 'before-value': + replaceState('value'); + break; + + case 'before-at-value': + replaceState('at-value'); + break; + } + + buffer += ch; + break; + } + } + + return tokens; +} diff --git a/lib/scopeify.js b/lib/scopeify.js deleted file mode 100644 index e1ccc6b..0000000 --- a/lib/scopeify.js +++ /dev/null @@ -1,68 +0,0 @@ -'use strict'; - -var fileScoper = require('./scoped-name'); - -var findClasses = /(\.)(?!\d)([^\s\.,{\[>+~#:)]*)(?![^{]*})/.source; -var findKeyframes = /(@\S*keyframes\s*)([^{\s]*)/.source; -var ignoreComments = /(?!(?:[^*/]|\*[^/]|\/[^*])*\*+\/)/.source; - -var classRegex = new RegExp(findClasses + ignoreComments, 'g'); -var keyframesRegex = new RegExp(findKeyframes + ignoreComments, 'g'); - -module.exports = scopify; - -function scopify(css, ignores) { - var makeScopedName = fileScoper(css); - var replacers = { - classes: classRegex, - keyframes: keyframesRegex - }; - - function scopeCss(result, key) { - var replacer = replacers[key]; - function replaceFn(fullMatch, prefix, name) { - var scopedName = ignores[name] ? name : makeScopedName(name); - result[key][scopedName] = name; - return prefix + scopedName; - } - return { - css: result.css.replace(replacer, replaceFn), - keyframes: result.keyframes, - classes: result.classes - }; - } - - var result = Object.keys(replacers).reduce(scopeCss, { - css: css, - keyframes: {}, - classes: {} - }); - - return replaceAnimations(result); -} - -function replaceAnimations(result) { - var animations = Object.keys(result.keyframes).reduce(function(acc, key) { - acc[result.keyframes[key]] = key; - return acc; - }, {}); - var unscoped = Object.keys(animations); - - if (unscoped.length) { - var regexStr = '((?:animation|animation-name)\\s*:[^};]*)(' - + unscoped.join('|') + ')([;\\s])' + ignoreComments; - var regex = new RegExp(regexStr, 'g'); - - var replaced = result.css.replace(regex, function(match, preamble, name, ending) { - return preamble + animations[name] + ending; - }); - - return { - css: replaced, - keyframes: result.keyframes, - classes: result.classes - } - } - - return result; -} diff --git a/lib/scopify.js b/lib/scopify.js new file mode 100644 index 0000000..2327333 --- /dev/null +++ b/lib/scopify.js @@ -0,0 +1,72 @@ +'use strict'; + +var classRegex = /(\.)(?!\d)([^\s\.,{\[>+~#:)]+)/g; +var keyframeRegex = /[^\s]+/g; + +var fileScoper = require('./scoped-name'); + +module.exports = scopify; + +function scopify(css, tokens, ignores) { + + var makeScopedName = fileScoper(css); + var result = {classes: {}, keyframes: {}}; + var scoped = css; + + function replaceFn(fullMatch, prefix, name) { + var scopedName = ignores[name] ? name : makeScopedName(name); + result.classes[scopedName] = name; + return prefix + scopedName; + } + + function replaceFnK(fullMatch, prefix, name, suffix) { + var scopedName = ignores[name] ? name : makeScopedName(name); + result.keyframes[name] = scopedName; + return prefix + scopedName + suffix; + } + + // iterate through keyframes + tokens.filter(function(token) { + return token.type === 'keyframes'; + }).forEach(function(token) { + token.name.replace(/(\s*)([^\s]+)([\s\S]*)/, replaceFnK); + }); + + // build regex + var keyframeNames = Object.keys(result.keyframes); + if (keyframeNames.length) { + var animRegex = new RegExp('(' + keyframeNames.join('|') + ')([;\\s]+|$)'); + } + + for (var i = tokens.length - 1; i >=0; i--) { + var token = tokens[i]; + var scopedToken = null; + if (token.type === 'selector') { + scopedToken = token.text.replace(classRegex, replaceFn); + } else if (token.type === 'keyframes') { + scopedToken = '@' + (token.prefix || '') + token.type + + token.name.replace(/(\s*)([^\s]+)([\s\S]*)/, replaceFnK); + } else if ( + token.type === 'property' && + (token.name === 'animation' || token.name === 'animation-name') && + animRegex + ) { + scopedToken = token.name + ': ' + token.value.replace(animRegex, function (full, match, suffix) { + return result.keyframes[match] + (suffix || ''); + }); + } + if (scopedToken) { + scoped = replace(scoped, token.start, token.end, scopedToken); + } + } + + return { + css: scoped, + keyframes: result.keyframes, + classes: result.classes + } +} + +function replace(str, start, end, what) { + return str.substring(0, start) + what + str.substring(end); +}; From b270a74b696c76638899ed3fd7920899b5061a4a Mon Sep 17 00:00:00 2001 From: Ryan Tsao Date: Tue, 5 Jul 2016 22:41:43 -0700 Subject: [PATCH 2/2] Added back compositions --- lib/csjs.js | 5 ++-- lib/css-extract-extends.js | 51 -------------------------------------- lib/scopify.js | 33 +++++++++++++++++++++++- 3 files changed, 34 insertions(+), 55 deletions(-) delete mode 100644 lib/css-extract-extends.js diff --git a/lib/csjs.js b/lib/csjs.js index 90bf7c0..5dc964d 100644 --- a/lib/csjs.js +++ b/lib/csjs.js @@ -1,6 +1,5 @@ 'use strict'; -var extractExtends = require('./css-extract-extends'); var isComposition = require('./composition').isComposition; var buildExports = require('./build-exports'); var scopify = require('./scopify'); @@ -29,9 +28,9 @@ module.exports = function csjsHandler(strings) { var localClasses = without(scoped.classes, ignores); var localKeyframes = without(scoped.keyframes, ignores); - // var compositions = extracted.compositions; + var compositions = scoped.compositions; - var exports = buildExports(localClasses, localKeyframes, {}); + var exports = buildExports(localClasses, localKeyframes, compositions); return Object.defineProperty(exports, cssKey, { enumerable: false, diff --git a/lib/css-extract-extends.js b/lib/css-extract-extends.js deleted file mode 100644 index 6b1a415..0000000 --- a/lib/css-extract-extends.js +++ /dev/null @@ -1,51 +0,0 @@ -'use strict'; - -var makeComposition = require('./composition').makeComposition; - -var regex = /\.([^\s]+)(\s+)(extends\s+)(\.[^{]+)/g; - -module.exports = function extractExtends(css, hashed) { - var found, matches = []; - while (found = regex.exec(css)) { - matches.unshift(found); - } - - function extractCompositions(acc, match) { - var extendee = getClassName(match[1]); - var keyword = match[3]; - var extended = match[4]; - - // remove from output css - var index = match.index + match[1].length + match[2].length; - var len = keyword.length + extended.length; - acc.css = acc.css.slice(0, index) + " " + acc.css.slice(index + len + 1); - - var extendedClasses = splitter(extended); - - extendedClasses.forEach(function(className) { - if (!acc.compositions[extendee]) { - acc.compositions[extendee] = {}; - } - if (!acc.compositions[className]) { - acc.compositions[className] = {}; - } - acc.compositions[extendee][className] = acc.compositions[className]; - }); - return acc; - } - - return matches.reduce(extractCompositions, { - css: css, - compositions: {} - }); - -}; - -function splitter(match) { - return match.split(',').map(getClassName); -} - -function getClassName(str) { - var trimmed = str.trim(); - return trimmed[0] === '.' ? trimmed.substr(1) : trimmed; -} diff --git a/lib/scopify.js b/lib/scopify.js index 2327333..fd900d6 100644 --- a/lib/scopify.js +++ b/lib/scopify.js @@ -3,6 +3,8 @@ var classRegex = /(\.)(?!\d)([^\s\.,{\[>+~#:)]+)/g; var keyframeRegex = /[^\s]+/g; +var extendsRegex = /\.([^\s]+)\s+extends\s+/; + var fileScoper = require('./scoped-name'); module.exports = scopify; @@ -38,11 +40,30 @@ function scopify(css, tokens, ignores) { var animRegex = new RegExp('(' + keyframeNames.join('|') + ')([;\\s]+|$)'); } + var compositions = {}; + for (var i = tokens.length - 1; i >=0; i--) { var token = tokens[i]; var scopedToken = null; if (token.type === 'selector') { scopedToken = token.text.replace(classRegex, replaceFn); + + var match = extendsRegex.exec(scopedToken); + if (match) { + var extendee = match[1]; + var extended = scopedToken.substr(match[0].length + match.index); + var extendedClasses = splitter(extended); + scopedToken = scopedToken.substr(0, match.index) + '.' + match[1] + ' '; + extendedClasses.forEach(function(className) { + if (!compositions[extendee]) { + compositions[extendee] = {}; + } + if (!compositions[className]) { + compositions[className] = {}; + } + compositions[extendee][className] = compositions[className]; + }); + } } else if (token.type === 'keyframes') { scopedToken = '@' + (token.prefix || '') + token.type + token.name.replace(/(\s*)([^\s]+)([\s\S]*)/, replaceFnK); @@ -63,10 +84,20 @@ function scopify(css, tokens, ignores) { return { css: scoped, keyframes: result.keyframes, - classes: result.classes + classes: result.classes, + compositions: compositions } } function replace(str, start, end, what) { return str.substring(0, start) + what + str.substring(end); }; + +function splitter(match) { + return match.split(',').map(getClassName); +} + +function getClassName(str) { + var trimmed = str.trim(); + return trimmed[0] === '.' ? trimmed.substr(1) : trimmed; +}