From 61ac715fb7415bd25670c713fe1c5054746f7339 Mon Sep 17 00:00:00 2001 From: Jacob Parker Date: Wed, 11 Jan 2017 16:00:13 +0100 Subject: [PATCH 1/6] Refactor to use external parser --- package.json | 3 +- src/index.js | 30 +++------- src/index.test.js | 20 +++---- src/transforms/border.js | 34 ++++++++++++ src/transforms/flex.js | 10 ++++ src/transforms/flexFlow.js | 34 ++++++++++++ src/transforms/font.js | 106 ++++++++++++++++++++++++++++++++++++ src/transforms/index.js | 57 +++++++++++++++++++ src/transforms/transform.js | 60 ++++++++++++++++++++ src/transforms/util.js | 39 +++++++++++++ 10 files changed, 359 insertions(+), 34 deletions(-) create mode 100644 src/transforms/border.js create mode 100644 src/transforms/flex.js create mode 100644 src/transforms/flexFlow.js create mode 100644 src/transforms/font.js create mode 100644 src/transforms/index.js create mode 100644 src/transforms/transform.js create mode 100644 src/transforms/util.js diff --git a/package.json b/package.json index ac50a10..23889e1 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "dependencies": { "css-color-list": "0.0.1", "fbjs": "^0.8.5", - "nearley": "^2.7.7" + "nearley": "^2.7.7", + "postcss-values-parser": "^1.0.1" } } diff --git a/src/index.js b/src/index.js index 4434420..a4cfc2a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,25 +1,7 @@ /* eslint-disable no-param-reassign */ -const nearley = require('nearley'); +const parser = require('postcss-values-parser/lib/index'); const camelizeStyleName = require('fbjs/lib/camelizeStyleName'); -const grammar = require('./grammar'); - -const transforms = [ - 'background', - 'border', - 'borderColor', - 'borderRadius', - 'borderWidth', - 'flex', - 'flexFlow', - 'font', - 'fontVariant', - 'fontWeight', - 'margin', - 'padding', - 'shadowOffset', - 'textShadowOffset', - 'transform', -]; +const transforms = require('./transforms'); const transformRawValue = input => ( (input !== '' && !isNaN(input)) @@ -27,13 +9,15 @@ const transformRawValue = input => ( : input ); -export const parseProp = (propName, value) => - new nearley.Parser(grammar.ParserRules, propName).feed(value).results[0]; +export const parseProp = (propName, value) => { + const ast = parser(value).parse(); + return transforms[propName](ast); +}; export const getStylesForProperty = (propName, inputValue, allowShorthand) => { const value = inputValue.trim(); - const propValue = (allowShorthand && transforms.indexOf(propName) !== -1) + const propValue = (allowShorthand && (propName in transforms)) ? parseProp(propName, value) : transformRawValue(value); diff --git a/src/index.test.js b/src/index.test.js index c30d6ad..c3518a6 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -14,16 +14,16 @@ it('transforms numbers', () => runTest([ ], { top: 0, left: 0, right: 0, bottom: 0 })); it('allows decimal values', () => { - expect(parseProp('number', '0.5')).toBe(0.5); - expect(parseProp('number', '1.5')).toBe(1.5); - expect(parseProp('number', '10.5')).toBe(10.5); - expect(parseProp('number', '100.5')).toBe(100.5); - expect(parseProp('number', '-0.5')).toBe(-0.5); - expect(parseProp('number', '-1.5')).toBe(-1.5); - expect(parseProp('number', '-10.5')).toBe(-10.5); - expect(parseProp('number', '-100.5')).toBe(-100.5); - expect(parseProp('number', '.5')).toBe(0.5); - expect(parseProp('number', '-.5')).toBe(-0.5); + expect(parseProp('margin', '0.5').$merge.marginTop).toBe(0.5); + expect(parseProp('margin', '1.5').$merge.marginTop).toBe(1.5); + expect(parseProp('margin', '10.5').$merge.marginTop).toBe(10.5); + expect(parseProp('margin', '100.5').$merge.marginTop).toBe(100.5); + expect(parseProp('margin', '-0.5').$merge.marginTop).toBe(-0.5); + expect(parseProp('margin', '-1.5').$merge.marginTop).toBe(-1.5); + expect(parseProp('margin', '-10.5').$merge.marginTop).toBe(-10.5); + expect(parseProp('margin', '-100.5').$merge.marginTop).toBe(-100.5); + expect(parseProp('margin', '.5').$merge.marginTop).toBe(0.5); + expect(parseProp('margin', '-.5').$merge.marginTop).toBe(-0.5); }); it('allows decimal values in transformed values', () => runTest([ diff --git a/src/transforms/border.js b/src/transforms/border.js new file mode 100644 index 0000000..6f24fae --- /dev/null +++ b/src/transforms/border.js @@ -0,0 +1,34 @@ +/* eslint-disable no-param-reassign */ +const defaultWidth = 1; +const defaultStyle = 'solid'; +const defaultColor = 'black'; + +const styles = ['solid', 'dotted', 'dashed']; + +module.exports = (root) => { + const { nodes } = root.first; + const values = nodes.reduce((accum, node) => { + if (accum.width === undefined && node.type === 'number') { + accum.width = Number(node.value); + } else if (accum.style === undefined && node.type === 'word' && styles.indexOf(node.value) !== -1) { + accum.style = node.value; + } else if (accum.color === undefined && node.type === 'word' && node.isColor) { + accum.color = node.value; + } else { + throw new Error(`Unexpected value: ${node}`); + } + return accum; + }, { + width: undefined, + style: undefined, + color: undefined, + }); + + const { + width: borderWidth = defaultWidth, + style: borderStyle = defaultStyle, + color: borderColor = defaultColor, + } = values; + + return { $merge: { borderWidth, borderStyle, borderColor } }; +}; diff --git a/src/transforms/flex.js b/src/transforms/flex.js new file mode 100644 index 0000000..119ce1b --- /dev/null +++ b/src/transforms/flex.js @@ -0,0 +1,10 @@ +const { assertUptoNValuesOfType } = require('./util'); + +module.exports = (root) => { + const { nodes } = root.first; + assertUptoNValuesOfType(3, 'number', nodes); + + const [flexGrow, flexShrink = 1, flexBasis = 0] = nodes.map(node => Number(node.value)); + + return { $merge: { flexGrow, flexShrink, flexBasis } }; +}; diff --git a/src/transforms/flexFlow.js b/src/transforms/flexFlow.js new file mode 100644 index 0000000..518382e --- /dev/null +++ b/src/transforms/flexFlow.js @@ -0,0 +1,34 @@ +/* eslint-disable no-param-reassign */ +const { assertUptoNValuesOfType } = require('./util'); + +const defaultWrap = 'nowrap'; +const defaultDirection = 'row'; + +const wraps = ['nowrap', 'wrap', 'wrap-reverse']; +const directions = ['row', 'row-reverse', 'column', 'column-reverse']; + +module.exports = (root) => { + const { nodes } = root.first; + assertUptoNValuesOfType(2, 'word', nodes); + + const values = nodes.reduce((accum, node) => { + if (accum.wrap === undefined && wraps.indexOf(node.value) !== -1) { + accum.wrap = node.value; + } else if (accum.direction === undefined && directions.indexOf(node.value) !== -1) { + accum.direction = node.value; + } else { + throw new Error(`Unexpected value: ${node}`); + } + return accum; + }, { + wrap: undefined, + direction: undefined, + }); + + const { + wrap: flexWrap = defaultWrap, + direction: flexDirection = defaultDirection, + } = values; + + return { $merge: { flexWrap, flexDirection } }; +}; diff --git a/src/transforms/font.js b/src/transforms/font.js new file mode 100644 index 0000000..7eb1483 --- /dev/null +++ b/src/transforms/font.js @@ -0,0 +1,106 @@ +/* eslint-disable no-param-reassign */ +const PARSE_STYLE_WEIGHT_VARIANT = 0; +const PARSE_SIZE = 1; +const PARSE_MAYBE_LINE_HEIGHT = 2; +const PARSE_LINE_HEIGHT = 3; +const PARSE_FONT_FAMILY = 4; +const PARSE_FINISHED = 5; + +const styles = ['italic']; +const weights = ['bold']; +const numericWeights = [100, 200, 300, 400, 500, 600, 700, 800, 900]; +const variants = ['small-caps']; + +module.exports = (root) => { + const { nodes } = root.first; + + const values = nodes.reduce((accum, node) => { + if (accum.parseMode === PARSE_STYLE_WEIGHT_VARIANT) { + let didMatchStyleWeightVariant = true; + const { type, value } = node; + + if (type === 'word' && value === 'normal') { + /* pass */ + } else if (accum.style === undefined && type === 'word' && styles.indexOf(value) !== -1) { + accum.style = value; + } else if (accum.weight === undefined && type === 'number' && numericWeights.indexOf(Number(value)) !== -1) { + accum.weight = String(value); + } else if (accum.weight === undefined && type === 'word' && weights.indexOf(value) !== -1) { + accum.weight = value; + } else if (accum.variant === undefined && type === 'word' && variants.indexOf(value) !== -1) { + accum.variant = [value]; + } else { + didMatchStyleWeightVariant = false; + } + + if (didMatchStyleWeightVariant) { + accum.numStyleWeightVariantMatched += 1; + if (accum.numStyleWeightVariantMatched === 3) accum.parseMode = PARSE_SIZE; + return accum; + } + + accum.parseMode = PARSE_SIZE; // fallthrough + } + + if (accum.parseMode === PARSE_SIZE) { + if (accum.size === undefined && node.type === 'number') { + accum.size = Number(node.value); + accum.parseMode = PARSE_MAYBE_LINE_HEIGHT; + return accum; + } + } + + if (accum.parseMode === PARSE_MAYBE_LINE_HEIGHT) { + if (node.type === 'operator' && node.value === '/') { + accum.parseMode = PARSE_LINE_HEIGHT; + return accum; + } + accum.parseMode = PARSE_FONT_FAMILY; // fallthrough + } + + if (accum.parseMode === PARSE_LINE_HEIGHT) { + if (accum.lineHeight === undefined && node.type === 'number') { + accum.lineHeight = Number(node.value); + accum.parseMode = PARSE_FONT_FAMILY; + return accum; + } + } + + if (accum.parseMode === PARSE_FONT_FAMILY) { + if (accum.family === undefined && node.type === 'string') { + accum.family = node.value; + accum.parseMode = PARSE_FINISHED; + return accum; + } else if (node.type === 'word') { + accum.family = `${(accum.family || '')} ${node.value}`; + return accum; + } + } + + throw new Error(`Unexpected value: ${node}`); + }, { + numStyleWeightVariantMatched: 0, + parseMode: PARSE_STYLE_WEIGHT_VARIANT, + style: undefined, + weight: undefined, + variant: undefined, + size: undefined, + lineHeight: undefined, + family: undefined, + }); + + const { + style: fontStyle = 'normal', + weight: fontWeight = 'normal', + variant: fontVariant = [], + size: fontSize, + family: fontFamily, + } = values; + + if (fontSize === undefined || fontFamily === undefined) throw new Error('Unexpected error'); + + const out = { fontStyle, fontWeight, fontVariant, fontSize, fontFamily }; + if (values.lineHeight !== undefined) out.lineHeight = values.lineHeight; + + return { $merge: out }; +}; diff --git a/src/transforms/index.js b/src/transforms/index.js new file mode 100644 index 0000000..7b1e7b6 --- /dev/null +++ b/src/transforms/index.js @@ -0,0 +1,57 @@ +const border = require('./border'); +const flex = require('./flex'); +const flexFlow = require('./flexFlow'); +const font = require('./font'); +const transform = require('./transform'); +const { directionFactory, shadowOffsetFactory } = require('./util'); + +const background = root => ({ $merge: { backgroundColor: String(root) } }); +const borderColor = directionFactory({ type: 'word', prefix: 'border', suffix: 'Color' }); +const borderRadius = directionFactory({ + directions: ['TopRight', 'BottomRight', 'BottomLeft', 'TopLeft'], + prefix: 'border', + suffix: 'Radius', +}); +const borderWidth = directionFactory({ prefix: 'border', suffix: 'Width' }); +const margin = directionFactory({ prefix: 'margin' }); +const padding = directionFactory({ prefix: 'padding' }); +const fontVariant = root => root.first.nodes.map(String); +const fontWeight = root => String(root); +const shadowOffset = shadowOffsetFactory('textShadowOffset'); +const textShadowOffset = shadowOffsetFactory(); + +// const transforms = [ +// 'background', +// 'border', +// 'borderColor', +// 'borderRadius', +// 'borderWidth', +// 'flex', +// 'flexFlow', +// 'font', +// 'fontVariant', +// 'fontWeight', +// 'margin', +// 'padding', +// 'shadowOffset', +// 'textShadowOffset', +// 'transform', +// ]; + +module.exports = { + background, + border, + borderColor, + borderRadius, + borderWidth, + flex, + flexFlow, + font, + fontVariant, + fontWeight, + margin, + padding, + shadowOffset, + textShadowOffset, + transform, +}; diff --git a/src/transforms/transform.js b/src/transforms/transform.js new file mode 100644 index 0000000..e0bc1ec --- /dev/null +++ b/src/transforms/transform.js @@ -0,0 +1,60 @@ +const { assertUptoNValuesOfType } = require('./util'); + +const singleNumber = nodes => Number(nodes[1].value); +const singleAngle = nodes => String(nodes[1]); +const xyTransformFactory = transform => (key, valueIfOmitted) => (nodes) => { + const [ + /* paren */, + xValue, + /* comma */, + yValue, + ] = nodes; + + if (xValue.type !== 'number' || (yValue && yValue.type !== 'number')) { + throw new Error('Expected values to be numbers'); + } + + const x = transform(xValue); + + if (valueIfOmitted === undefined && yValue === undefined) return x; + + const y = yValue !== undefined ? transform(yValue) : valueIfOmitted; + return [{ [`${key}Y`]: y }, { [`${key}X`]: x }]; +}; +const xyNumber = xyTransformFactory(node => Number(node.value)); +const xyAngle = xyTransformFactory(node => String(node).trim()); + +const partTransforms = { + perspective: singleNumber, + scale: xyNumber('scale'), + scaleX: singleNumber, + scaleY: singleNumber, + translate: xyNumber('translate', 0), + translateX: singleNumber, + translateY: singleNumber, + rotate: singleAngle, + rotateX: singleAngle, + rotateY: singleAngle, + rotateZ: singleAngle, + skewX: singleAngle, + skewY: singleAngle, + skew: xyAngle('skew', '0deg'), +}; + +module.exports = (root) => { + const { nodes } = root.first; + assertUptoNValuesOfType(Infinity, 'func', nodes); + + const transforms = nodes.reduce((accum, node) => { + if (!(node.value in partTransforms)) throw new Error(`Unrecognised transform: ${node.value}`); + + let transformedValues = partTransforms[node.value](node.nodes); + if (!Array.isArray(transformedValues)) { + transformedValues = [{ [node.value]: transformedValues }]; + } + + return transformedValues.concat(accum); + }, []); + + return transforms; +}; diff --git a/src/transforms/util.js b/src/transforms/util.js new file mode 100644 index 0000000..92f0235 --- /dev/null +++ b/src/transforms/util.js @@ -0,0 +1,39 @@ +const assertUptoNValuesOfType = (n, type, nodes) => { + nodes.forEach((value) => { + if (value.type !== type) throw new Error(`Expected all values to be of type ${type}`); + }); + if (nodes.length > 4) throw new Error('Expected no more than four values'); + if (nodes.length === 0) throw new Error('Expected some values'); +}; +module.exports.assertUptoNValuesOfType = assertUptoNValuesOfType; + +module.exports.directionFactory = ({ + type = 'number', + directions = ['Top', 'Right', 'Bottom', 'Left'], + prefix = '', + suffix = '', +}) => (root) => { + const { nodes } = root.first; + assertUptoNValuesOfType(4, type, nodes); + let values = nodes.map(node => node.value); + if (type === 'number') values = values.map(Number); + const [top, right = top, bottom = top, left = right] = values; + + const keyFor = n => `${prefix}${directions[n]}${suffix}`; + + const output = { + [keyFor(0)]: top, + [keyFor(1)]: right, + [keyFor(2)]: bottom, + [keyFor(3)]: left, + }; + + return { $merge: output }; +}; + +module.exports.shadowOffsetFactory = () => (root) => { + const { nodes } = root.first; + assertUptoNValuesOfType(2, 'number', nodes); + const [width, height = width] = nodes.map(node => Number(node.value)); + return { width, height }; +}; From b30abe33cdbc81cb89a90e72cb0fb3f5da8a36fe Mon Sep 17 00:00:00 2001 From: Jacob Parker Date: Mon, 23 Jan 2017 11:31:29 +0000 Subject: [PATCH 2/6] Use postcss-value-parser, add units --- package.json | 8 +- pegjs.json | 9 -- src/TokenStream.js | 71 +++++++++++++ src/grammar.ne | 196 ------------------------------------ src/index.js | 8 +- src/index.test.js | 91 ++++++++++------- src/tokenTypes.js | 51 ++++++++++ src/transforms/border.js | 54 +++++----- src/transforms/flex.js | 44 +++++++- src/transforms/flexFlow.js | 50 ++++----- src/transforms/font.js | 141 ++++++++++---------------- src/transforms/index.js | 29 ++---- src/transforms/transform.js | 79 +++++++++------ src/transforms/util.js | 40 ++++---- 14 files changed, 408 insertions(+), 463 deletions(-) delete mode 100644 pegjs.json create mode 100644 src/TokenStream.js delete mode 100644 src/grammar.ne create mode 100644 src/tokenTypes.js diff --git a/package.json b/package.json index 23889e1..d30c678 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "description": "Convert CSS text to a React Native stylesheet object", "main": "dist/index.js", "scripts": { - "build-grammar": "nearleyc src/grammar.ne -o src/grammar.js", - "build": "npm run build-grammar; babel src --ignore test.js --out-dir dist", - "test": "npm run build-grammar; jest", + "build": "babel src --ignore test.js --out-dir dist", + "test": "jest", + "test:watch": "jest --watch", "prepublish": "npm run build" }, "repository": { @@ -35,9 +35,11 @@ "jest": "^17.0.0" }, "dependencies": { + "css-color-keywords": "^1.0.0", "css-color-list": "0.0.1", "fbjs": "^0.8.5", "nearley": "^2.7.7", + "postcss-value-parser": "^3.3.0", "postcss-values-parser": "^1.0.1" } } diff --git a/pegjs.json b/pegjs.json deleted file mode 100644 index 74e1032..0000000 --- a/pegjs.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "optimize": "size", - "allowedStartRules": [ - "FontWeight", - "FontVariant", - "ShadowOffset", - "Transform" - ] -} \ No newline at end of file diff --git a/src/TokenStream.js b/src/TokenStream.js new file mode 100644 index 0000000..560fd33 --- /dev/null +++ b/src/TokenStream.js @@ -0,0 +1,71 @@ +module.exports = class TokenStream { + constructor(nodes, parent) { + this.nodes = nodes; + this.parent = parent; + this.lastFunction = null; + this.lastValue = null; + } + + get node() { + return this.nodes[0]; + } + + hasTokens() { + return this.nodes.length > 0; + } + + lookahead() { + return new TokenStream(this.nodes.slice(1), this.parent); + } + + match(...tokenDescriptors) { + const node = this.node; + + if (!node) return null; + + /* eslint-disable no-restricted-syntax */ + for (const tokenDescriptor of tokenDescriptors) { + const value = tokenDescriptor(node); + + if (value !== null) { + this.nodes = this.nodes.slice(1); + this.lastFunction = null; + this.lastValue = value; + return value; + } + } + /* eslint-enable */ + + return null; + } + + expect(...tokenDescriptors) { + const value = this.match(...tokenDescriptors); + if (value !== null) return value; + return this.throw(); + } + + matchFunction() { + const node = this.node; + if (node.type !== 'function') return null; + const value = new TokenStream(node.nodes, node); + this.nodes = this.nodes.slice(1); + this.lastFunction = value; + this.lastValue = null; + return value; + } + + expectFunction() { + const value = this.matchFunction(); + if (value !== null) return value; + return this.throw(); + } + + expectEmpty() { + if (this.hasTokens()) this.throw(); + } + + throw() { + throw new Error(`Unexpected token type: ${this.node.type}`); + } +}; diff --git a/src/grammar.ne b/src/grammar.ne deleted file mode 100644 index debbc08..0000000 --- a/src/grammar.ne +++ /dev/null @@ -1,196 +0,0 @@ -@{% - const cssColorList = require('css-color-list')(); - - const at = index => d => d && d[index]; - const pick = indices => d => indices.map(index => d[index]); - const text = d => Array.isArray(d) ? d.map(text).join('') : d; - const transformArg1 = d => ({ [d[0].join('')]: d[2][0] }); - const defaultOptional = (value, defaultValue) => value === null ? defaultValue : value; - - const combineHeadTail = ([head, tail]) => { - const tailValues = tail.reduce((accum, value) => ( - accum.concat(value[1]) - ), []); - return [].concat(head, tailValues); - }; - - const combineAnyOrder = index => d => { - const array = d[2].slice(); - array.splice(index, 0, d[0][0]); - return array; - } - - const combineClockwiseShorthand = ( - prefix, - keys = ['Top', 'Right', 'Bottom', 'Left'], - suffix = '' - ) => (d, location, reject) => { - const values = combineHeadTail(d); - - if (values.length > 4) return reject; - - const [top, right = top, bottom = top, left = right] = values; - return { $merge: { - [prefix + keys[0] + suffix]: top, - [prefix + keys[1] + suffix]: right, - [prefix + keys[2] + suffix]: bottom, - [prefix + keys[3] + suffix]: left, - } }; - } - - const transformArg1XY = yValue => d => { - const fn = d[0]; - return [{ [`${fn}X`]: d[2][0] }, { [`${fn}Y`]: yValue }]; - }; - const transformArg2 = d => { - const fn = d[0]; - const [arg1, arg2] = d[2]; - return [{ [`${fn}X`]: arg1[0] }, { [`${fn}Y`]: arg2[0] }]; - }; -%} - -int - -> "0" | [1-9] [0-9]:* - -decimal - -> "." [0-9]:+ - -number - -> "-":? (int decimal | int | decimal) {% d => Number(text(d)) %} - -angle -> number ("deg" | "rad") {% text %} - -ident -> ("-":? [_A-Za-z] [_A-Za-z0-9-]:*) {% text %} -# ident -> [^ ]:+ {% text %} - -color - -> "#" ([a-fA-F0-9]:*) {% text %} - | ("rgb" | "hsl" | "hsv") ("a":?) "(" ([^)]:+) ")" {% text %} - | ([A-Za-z]:+) {% (d, location, reject) => { - const name = text(d).toLowerCase(); - return cssColorList.indexOf(name) !== -1 ? name : reject; - } %} - -_ -> [ \t\n\r]:* {% () => null %} -__ -> [ \t\n\r]:+ {% () => null %} - -anyOrder2[a, b] - -> $a __ $b {% d => [d[0][0][0], d[2][0][0]] %} - | $b __ $a {% d => [d[2][0][0], d[0][0][0]] %} - -anyOrder3[a, b, c] - -> $a __ anyOrder2[$b, $c] {% combineAnyOrder(0) %} - | $b __ anyOrder2[$a, $c] {% combineAnyOrder(1) %} - | $c __ anyOrder2[$a, $b] {% combineAnyOrder(2) %} - -anyOrderOptional2[a, b] - -> anyOrder2[$a, $b] {% at(0) %} - | $a {% d => [d[0][0], null] %} - | $b {% d => [null, d[0][0]] %} - -anyOrderOptional3[a, b, c] - -> anyOrder3[$a, $b, $c] {% d => d[0].map(at(0)) %} - | anyOrder2[$a, $b] {% d => [d[0][0], d[0][1], null] %} - | anyOrder2[$a, $c] {% d => [d[0][0], null, d[0][1]] %} - | anyOrder2[$b, $c] {% d => [null, d[0][0], d[0][1]] %} - | $a {% d => [d[0][0], null, null] %} - | $b {% d => [null, d[0][0], null] %} - | $c {% d => [null, null, d[0][0]] %} - -anyOrderOptional3AllowNull[a, b, c] - -> anyOrderOptional3[$a, $b, $c]:? {% d => (d[0] ? d[0].map(at(0)) : [null, null, null]) %} - -transformArg1[t] -> "(" _ $t _ ")" {% at(2) %} -transformArg2[t] -> "(" _ $t _ "," _ $t _ ")" {% pick([2, 6]) %} - -transformPart - -> ("perspective" | "scale" [XY]:? | "translate" [XY]) _ transformArg1[number] {% transformArg1 %} - | ("rotate" [XYZ]:? | "skew" [XY]) _ transformArg1[angle] {% transformArg1 %} - | ("translate") _ transformArg1[number] {% transformArg1XY(0) %} - | ("skew") _ transformArg1[angle] {% transformArg1XY('0deg') %} - | ("scale" | "translate") _ transformArg2[number] {% transformArg2 %} - | ("skew") _ transformArg2[angle] {% transformArg2 %} - -transform - -> transformPart (_ transformPart):* {% d => combineHeadTail(d).reverse() %} - -shadowOffset - -> number __ number {% d => ({ width: d[0], height: d[2] }) %} - -textShadowOffset - -> shadowOffset {% at(0) %} - -fontVariant - -> ident (__ ident):* {% combineHeadTail %} - -fontWeight - -> .:+ {% text %} - -background - -> color {% d => ({ $merge: { backgroundColor: d[0] } }) %} - -border - -> anyOrderOptional3[number, ident, color] {% d => ({ $merge: { - borderWidth: defaultOptional(d[0][0], 1), - borderStyle: defaultOptional(d[0][1], 'solid'), - borderColor: defaultOptional(d[0][2], 'black'), - } }) %} - -margin - -> number (__ number):* {% combineClockwiseShorthand('margin') %} - -padding - -> number (__ number):* {% combineClockwiseShorthand('padding') %} - -borderWidth - -> number (__ number):* {% combineClockwiseShorthand('border', undefined, 'Width') %} - -borderColor - -> color (__ color):* {% combineClockwiseShorthand('border', undefined, 'Color') %} - -borderRadius - -> number (__ number):* {% - combineClockwiseShorthand('border', ['TopLeft', 'TopRight', 'BottomRight', 'BottomLeft'], 'Radius') - %} - -flexFlowFlexWrap -> ("nowrap" | "wrap" | "wrap-reverse") {% text %} -flexFlowFlexDirection -> ("row" | "row-reverse" | "column" | "column-reverse") {% text %} - -flexFlow - -> anyOrderOptional2[flexFlowFlexWrap, flexFlowFlexDirection] {% d => ({ $merge: { - flexWrap: defaultOptional(d[0][0], 'nowrap'), - flexDirection: defaultOptional(d[0][1], 'row'), - } }) %} - -flex - -> number (__ number):* {% (d, location, reject) => { - const values = combineHeadTail(d); - if (values.length > 3) return reject; - const [flexGrow, flexShrink = 1, flexBasis = 0] = values; - return { $merge: { flexGrow, flexShrink, flexBasis } }; - } %} - -fontFontStyle -> ("normal" | "italic") {% text %} -fontFontVariantCss21 -> "normal" {% () => [] %} | "small-caps" {% () => ['small-caps'] %} -fontFontWeight -> ("normal" | "bold" | [1-9] "00") {% text %} -fontFontFamily - -> "\"" ("\\" . | [^"]):* "\"" {% d => text(d[1]) %} - | "'" ("\\" . | [^']):* "'" {% d => text(d[1]) %} - -font - -> anyOrderOptional3AllowNull[fontFontStyle, fontFontVariantCss21, fontFontWeight] _ - number (_ "/" _ number):? __ - fontFontFamily {% d => { - const options = { - fontStyle: defaultOptional(d[0][0], 'normal'), - fontVariant: defaultOptional(d[0][1], []), - fontWeight: defaultOptional(d[0][2], 'normal'), - fontSize: d[2], - fontFamily: d[5] - }; - // In CSS, line-height defaults to normal, but we can't set it to that - const lineHeight = d[3] && d[3][3]; - if (lineHeight) options.lineHeight = lineHeight; - - return { $merge: options }; - } %} diff --git a/src/index.js b/src/index.js index a4cfc2a..abc0c84 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,8 @@ /* eslint-disable no-param-reassign */ -const parser = require('postcss-values-parser/lib/index'); +const parse = require('postcss-value-parser'); const camelizeStyleName = require('fbjs/lib/camelizeStyleName'); const transforms = require('./transforms'); +const TokenStream = require('./TokenStream'); const transformRawValue = input => ( (input !== '' && !isNaN(input)) @@ -10,8 +11,9 @@ const transformRawValue = input => ( ); export const parseProp = (propName, value) => { - const ast = parser(value).parse(); - return transforms[propName](ast); + const ast = parse(value).nodes; + const tokenStream = new TokenStream(ast); + return transforms[propName](tokenStream); }; export const getStylesForProperty = (propName, inputValue, allowShorthand) => { diff --git a/src/index.test.js b/src/index.test.js index c3518a6..3168926 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -14,20 +14,20 @@ it('transforms numbers', () => runTest([ ], { top: 0, left: 0, right: 0, bottom: 0 })); it('allows decimal values', () => { - expect(parseProp('margin', '0.5').$merge.marginTop).toBe(0.5); - expect(parseProp('margin', '1.5').$merge.marginTop).toBe(1.5); - expect(parseProp('margin', '10.5').$merge.marginTop).toBe(10.5); - expect(parseProp('margin', '100.5').$merge.marginTop).toBe(100.5); - expect(parseProp('margin', '-0.5').$merge.marginTop).toBe(-0.5); - expect(parseProp('margin', '-1.5').$merge.marginTop).toBe(-1.5); - expect(parseProp('margin', '-10.5').$merge.marginTop).toBe(-10.5); - expect(parseProp('margin', '-100.5').$merge.marginTop).toBe(-100.5); - expect(parseProp('margin', '.5').$merge.marginTop).toBe(0.5); - expect(parseProp('margin', '-.5').$merge.marginTop).toBe(-0.5); + expect(parseProp('margin', '0.5px').$merge.marginTop).toBe(0.5); + expect(parseProp('margin', '1.5px').$merge.marginTop).toBe(1.5); + expect(parseProp('margin', '10.5px').$merge.marginTop).toBe(10.5); + expect(parseProp('margin', '100.5px').$merge.marginTop).toBe(100.5); + expect(parseProp('margin', '-0.5px').$merge.marginTop).toBe(-0.5); + expect(parseProp('margin', '-1.5px').$merge.marginTop).toBe(-1.5); + expect(parseProp('margin', '-10.5px').$merge.marginTop).toBe(-10.5); + expect(parseProp('margin', '-100.5px').$merge.marginTop).toBe(-100.5); + expect(parseProp('margin', '.5px').$merge.marginTop).toBe(0.5); + expect(parseProp('margin', '-.5px').$merge.marginTop).toBe(-0.5); }); it('allows decimal values in transformed values', () => runTest([ - ['border-radius', '1.5'], + ['border-radius', '1.5px'], ], { borderTopLeftRadius: 1.5, borderTopRightRadius: 1.5, @@ -36,7 +36,7 @@ it('allows decimal values in transformed values', () => runTest([ })); it('allows negative values in transformed values', () => runTest([ - ['border-radius', '-1.5'], + ['border-radius', '-1.5px'], ], { borderTopLeftRadius: -1.5, borderTopRightRadius: -1.5, @@ -81,11 +81,11 @@ it('transforms font variant as an array', () => runTest([ ], { fontVariant: ['tabular-nums'] })); it('transforms shadow offsets', () => runTest([ - ['shadow-offset', ' 10 5'], + ['shadow-offset', '10px 5px'], ], { shadowOffset: { width: 10, height: 5 } })); it('transforms text shadow offsets', () => runTest([ - ['text-shadow-offset', '10 5'], + ['text-shadow-offset', '10px 5px'], ], { textShadowOffset: { width: 10, height: 5 } })); it('transforms a single transform value with number', () => runTest([ @@ -108,12 +108,12 @@ it('transforms scale(number) to scale', () => runTest([ ['transform', 'scale(5)'], ], { transform: [{ scale: 5 }] })); -it('transforms translate(number, number) to translateX and translateY', () => runTest([ - ['transform', 'translate(2, 3)'], +it('transforms translate(length, length) to translateX and translateY', () => runTest([ + ['transform', 'translate(2px, 3px)'], ], { transform: [{ translateY: 3 }, { translateX: 2 }] })); -it('transforms translate(number) to translateX and translateY', () => runTest([ - ['transform', 'translate(5)'], +it('transforms translate(length) to translateX and translateY', () => runTest([ + ['transform', 'translate(5px)'], ], { transform: [{ translateY: 0 }, { translateX: 5 }] })); it('transforms skew(angle, angle) to skewX and skewY', () => runTest([ @@ -125,19 +125,19 @@ it('transforms skew(angle) to skewX and skewY', () => runTest([ ], { transform: [{ skewY: '0deg' }, { skewX: '5deg' }] })); it('transforms border shorthand', () => runTest([ - ['border', '2 dashed #f00'], + ['border', '2px dashed #f00'], ], { borderWidth: 2, borderColor: '#f00', borderStyle: 'dashed' })); it('transforms border shorthand in other order', () => runTest([ - ['border', '#f00 2 dashed'], + ['border', '#f00 2px dashed'], ], { borderWidth: 2, borderColor: '#f00', borderStyle: 'dashed' })); it('transforms border shorthand missing color', () => runTest([ - ['border', '2 dashed'], + ['border', '2px dashed'], ], { borderWidth: 2, borderColor: 'black', borderStyle: 'dashed' })); it('transforms border shorthand missing style', () => runTest([ - ['border', '2 #f00'], + ['border', '2px #f00'], ], { borderWidth: 2, borderColor: '#f00', borderStyle: 'solid' })); it('transforms border shorthand missing width', () => runTest([ @@ -153,32 +153,36 @@ it('transforms border shorthand missing style & width', () => runTest([ ], { borderWidth: 1, borderColor: '#f00', borderStyle: 'solid' })); it('transforms border shorthand missing color & style', () => runTest([ - ['border', '2'], + ['border', '2px'], ], { borderWidth: 2, borderColor: 'black', borderStyle: 'solid' })); it('transforms margin shorthands using 4 values', () => runTest([ - ['margin', '10 20 30 40'], + ['margin', '10px 20px 30px 40px'], ], { marginTop: 10, marginRight: 20, marginBottom: 30, marginLeft: 40 })); it('transforms margin shorthands using 3 values', () => runTest([ - ['margin', '10 20 30'], + ['margin', '10px 20px 30px'], ], { marginTop: 10, marginRight: 20, marginBottom: 30, marginLeft: 20 })); it('transforms margin shorthands using 2 values', () => runTest([ - ['margin', '10 20'], + ['margin', '10px 20px'], ], { marginTop: 10, marginRight: 20, marginBottom: 10, marginLeft: 20 })); it('transforms margin shorthands using 1 value', () => runTest([ - ['margin', '10'], + ['margin', '10px'], ], { marginTop: 10, marginRight: 10, marginBottom: 10, marginLeft: 10 })); it('shorthand with 1 value should override previous values', () => runTest([ - ['margin-top', '2'], - ['margin', '1'], + ['margin-top', '2px'], + ['margin', '1px'], ], { marginTop: 1, marginRight: 1, marginBottom: 1, marginLeft: 1 })); it('transforms flex shorthand with 3 values', () => runTest([ - ['flex', '1 2 3'], + ['flex', '1 2 3px'], +], { flexGrow: 1, flexShrink: 2, flexBasis: 3 })); + +it('transforms flex shorthand with 3 values in reverse order', () => runTest([ + ['flex', '3px 1 2'], ], { flexGrow: 1, flexShrink: 2, flexBasis: 3 })); it('transforms flex shorthand with 2 values', () => runTest([ @@ -202,7 +206,7 @@ it('transforms flexFlow shorthand missing flexWrap', () => runTest([ ], { flexDirection: 'column', flexWrap: 'nowrap' })); it('transforms font', () => runTest([ - ['font', 'bold italic small-caps 16/18 "Helvetica"'], + ['font', 'bold italic small-caps 16px/18px "Helvetica"'], ], { fontFamily: 'Helvetica', fontSize: 16, @@ -213,7 +217,7 @@ it('transforms font', () => runTest([ })); it('transforms font missing font-variant', () => runTest([ - ['font', 'bold italic 16/18 "Helvetica"'], + ['font', 'bold italic 16px/18px "Helvetica"'], ], { fontFamily: 'Helvetica', fontSize: 16, @@ -224,7 +228,7 @@ it('transforms font missing font-variant', () => runTest([ })); it('transforms font missing font-style', () => runTest([ - ['font', 'bold small-caps 16/18 "Helvetica"'], + ['font', 'bold small-caps 16px/18px "Helvetica"'], ], { fontFamily: 'Helvetica', fontSize: 16, @@ -235,7 +239,7 @@ it('transforms font missing font-style', () => runTest([ })); it('transforms font missing font-weight', () => runTest([ - ['font', 'italic small-caps 16/18 "Helvetica"'], + ['font', 'italic small-caps 16px/18px "Helvetica"'], ], { fontFamily: 'Helvetica', fontSize: 16, @@ -246,7 +250,7 @@ it('transforms font missing font-weight', () => runTest([ })); it('transforms font with font-weight normal', () => runTest([ - ['font', 'normal 16/18 "Helvetica"'], + ['font', 'normal 16px/18px "Helvetica"'], ], { fontFamily: 'Helvetica', fontSize: 16, @@ -257,7 +261,7 @@ it('transforms font with font-weight normal', () => runTest([ })); it('transforms font with font-weight and font-style normal', () => runTest([ - ['font', 'normal normal 16/18 "Helvetica"'], + ['font', 'normal normal 16px/18px "Helvetica"'], ], { fontFamily: 'Helvetica', fontSize: 16, @@ -268,7 +272,7 @@ it('transforms font with font-weight and font-style normal', () => runTest([ })); it('transforms font with no font-weight, font-style, and font-variant', () => runTest([ - ['font', '16/18 "Helvetica"'], + ['font', '16px/18px "Helvetica"'], ], { fontFamily: 'Helvetica', fontSize: 16, @@ -279,13 +283,24 @@ it('transforms font with no font-weight, font-style, and font-variant', () => ru })); it('omits line height if not specified', () => runTest([ - ['font', '16 "Helvetica"'], + ['font', '16px "Helvetica"'], +], { + fontFamily: 'Helvetica', + fontSize: 16, + fontWeight: 'normal', + fontStyle: 'normal', + fontVariant: [], +})); + +it('allows line height as multiple', () => runTest([ + ['font', '16px/1.5 "Helvetica"'], ], { fontFamily: 'Helvetica', fontSize: 16, fontWeight: 'normal', fontStyle: 'normal', fontVariant: [], + lineHeight: 24, })); it('allows blacklisting shorthands', () => { diff --git a/src/tokenTypes.js b/src/tokenTypes.js new file mode 100644 index 0000000..6bbee75 --- /dev/null +++ b/src/tokenTypes.js @@ -0,0 +1,51 @@ +const { stringify } = require('postcss-value-parser'); +const cssColorKeywords = require('css-color-keywords'); + +const hexColorRe = /^(#(?:[0-9a-f]{3,4}){1,2})$/i; +const cssFunctionNameRe = /^(rgba?|hsla?|hwb|lab|lch|gray|color)$/; + +const matchColor = (node) => { + if (node.type === 'word' && (hexColorRe.test(node.value) || node.value in cssColorKeywords)) { + return node.value; + } else if (node.type === 'function' && cssFunctionNameRe.test(node.value)) { + return stringify(node); + } + return null; +}; + +const noneRe = /^(none)$/; +const numberRe = /^([+-]?(?:\d*\.)?\d+(?:[Ee][+-]?\d+)?)$/; +const lengthRe = /^([+-]?(?:\d*\.)?\d+(?:[Ee][+-]?\d+)?)px$/; +const angleRe = /^([+-]?(?:\d*\.)?\d+(?:[Ee][+-]?\d+)?(?:deg|rad))$/; +const percentRe = /^([+-]?(?:\d*\.)?\d+(?:[Ee][+-]?\d+)?%)$/; + +const noopToken = predicate => node => (predicate(node) ? '' : null); + +const valueForTypeToken = type => node => (node.type === type ? node.value : null); + +const regExpToken = (regExp, transform = String) => (node) => { + if (node.type !== 'word') return null; + + const match = node.value.match(regExp); + if (!match) return null; + + const value = transform(match[1]); + + return value; +}; + +module.exports.regExpToken = regExpToken; + +module.exports.tokens = { + SPACE: noopToken(node => node.type === 'space'), + SLASH: noopToken(node => node.type === 'div' && node.value === '/'), + COMMA: noopToken(node => node.type === 'div' && node.value === ','), + STRING: valueForTypeToken('string'), + WORD: valueForTypeToken('word'), + NONE: regExpToken(noneRe), + NUMBER: regExpToken(numberRe, Number), + LENGTH: regExpToken(lengthRe, Number), + ANGLE: regExpToken(angleRe), + PERCENT: regExpToken(percentRe), + COLOR: matchColor, +}; diff --git a/src/transforms/border.js b/src/transforms/border.js index 6f24fae..c7528d3 100644 --- a/src/transforms/border.js +++ b/src/transforms/border.js @@ -1,34 +1,40 @@ +const { regExpToken, tokens } = require('../tokenTypes'); + +const { SPACE, COLOR, LENGTH } = tokens; +const BORDER_STYLE = regExpToken(/^(solid|dashed|dotted)$/); + /* eslint-disable no-param-reassign */ const defaultWidth = 1; const defaultStyle = 'solid'; const defaultColor = 'black'; -const styles = ['solid', 'dotted', 'dashed']; - -module.exports = (root) => { - const { nodes } = root.first; - const values = nodes.reduce((accum, node) => { - if (accum.width === undefined && node.type === 'number') { - accum.width = Number(node.value); - } else if (accum.style === undefined && node.type === 'word' && styles.indexOf(node.value) !== -1) { - accum.style = node.value; - } else if (accum.color === undefined && node.type === 'word' && node.isColor) { - accum.color = node.value; +module.exports = (tokenStream) => { + let borderWidth; + let borderColor; + let borderStyle; + + let numParsed = 0; + while (numParsed < 3 && tokenStream.hasTokens()) { + if (numParsed) tokenStream.expect(SPACE); + + if (borderWidth === undefined && tokenStream.match(LENGTH)) { + borderWidth = tokenStream.lastValue; + } else if (borderColor === undefined && tokenStream.match(COLOR)) { + borderColor = tokenStream.lastValue; + } else if (borderStyle === undefined && tokenStream.match(BORDER_STYLE)) { + borderStyle = tokenStream.lastValue; } else { - throw new Error(`Unexpected value: ${node}`); + tokenStream.throw(); } - return accum; - }, { - width: undefined, - style: undefined, - color: undefined, - }); - - const { - width: borderWidth = defaultWidth, - style: borderStyle = defaultStyle, - color: borderColor = defaultColor, - } = values; + + numParsed += 1; + } + + tokenStream.expectEmpty(); + + if (borderWidth === undefined) borderWidth = defaultWidth; + if (borderColor === undefined) borderColor = defaultColor; + if (borderStyle === undefined) borderStyle = defaultStyle; return { $merge: { borderWidth, borderStyle, borderColor } }; }; diff --git a/src/transforms/flex.js b/src/transforms/flex.js index 119ce1b..ff48a4e 100644 --- a/src/transforms/flex.js +++ b/src/transforms/flex.js @@ -1,10 +1,44 @@ -const { assertUptoNValuesOfType } = require('./util'); +const { tokens } = require('../tokenTypes'); -module.exports = (root) => { - const { nodes } = root.first; - assertUptoNValuesOfType(3, 'number', nodes); +const { NONE, NUMBER, LENGTH, SPACE } = tokens; - const [flexGrow, flexShrink = 1, flexBasis = 0] = nodes.map(node => Number(node.value)); +const defaultFlexGrow = 0; +const defaultFlexShrink = 1; +const defaultFlexBasis = 0; + +module.exports = (tokenStream) => { + let flexGrow; + let flexShrink; + let flexBasis; + + if (tokenStream.match(NONE)) { + tokenStream.expectEmpty(); + return { $merge: { flexGrow: 0, flexShrink: 0 } }; + } + + let partsParsed = 0; + while (partsParsed < 2 && tokenStream.hasTokens()) { + if (partsParsed) tokenStream.expect(SPACE); + + if (flexGrow === undefined && tokenStream.match(NUMBER)) { + flexGrow = tokenStream.lastValue; + + if (tokenStream.lookahead().match(NUMBER)) { + tokenStream.expect(SPACE); + flexShrink = tokenStream.match(NUMBER); + } + } else if (flexBasis === undefined && tokenStream.match(LENGTH)) { + flexBasis = tokenStream.lastValue; + } + + partsParsed += 1; + } + + tokenStream.expectEmpty(); + + if (flexGrow === undefined) flexGrow = defaultFlexGrow; + if (flexShrink === undefined) flexShrink = defaultFlexShrink; + if (flexBasis === undefined) flexBasis = defaultFlexBasis; return { $merge: { flexGrow, flexShrink, flexBasis } }; }; diff --git a/src/transforms/flexFlow.js b/src/transforms/flexFlow.js index 518382e..52c8bb7 100644 --- a/src/transforms/flexFlow.js +++ b/src/transforms/flexFlow.js @@ -1,34 +1,34 @@ /* eslint-disable no-param-reassign */ -const { assertUptoNValuesOfType } = require('./util'); +const { tokens, regExpToken } = require('../tokenTypes'); -const defaultWrap = 'nowrap'; -const defaultDirection = 'row'; +const { SPACE } = tokens; +const WRAP = regExpToken(/(nowrap|wrap|wrap-reverse)/); +const DIRECTION = regExpToken(/(row|row-reverse|column|column-reverse)/); -const wraps = ['nowrap', 'wrap', 'wrap-reverse']; -const directions = ['row', 'row-reverse', 'column', 'column-reverse']; +const defaultFlexWrap = 'nowrap'; +const defaultFlexDirection = 'row'; -module.exports = (root) => { - const { nodes } = root.first; - assertUptoNValuesOfType(2, 'word', nodes); +module.exports = (tokenStream) => { + let flexWrap; + let flexDirection; - const values = nodes.reduce((accum, node) => { - if (accum.wrap === undefined && wraps.indexOf(node.value) !== -1) { - accum.wrap = node.value; - } else if (accum.direction === undefined && directions.indexOf(node.value) !== -1) { - accum.direction = node.value; - } else { - throw new Error(`Unexpected value: ${node}`); + let numParsed = 0; + while (numParsed < 2 && tokenStream.hasTokens()) { + if (numParsed) tokenStream.expect(SPACE); + + if (flexWrap === undefined && tokenStream.match(WRAP)) { + flexWrap = tokenStream.lastValue; + } else if (flexDirection === undefined && tokenStream.match(DIRECTION)) { + flexDirection = tokenStream.lastValue; } - return accum; - }, { - wrap: undefined, - direction: undefined, - }); - - const { - wrap: flexWrap = defaultWrap, - direction: flexDirection = defaultDirection, - } = values; + + numParsed += 1; + } + + tokenStream.expectEmpty(); + + if (flexWrap === undefined) flexWrap = defaultFlexWrap; + if (flexDirection === undefined) flexDirection = defaultFlexDirection; return { $merge: { flexWrap, flexDirection } }; }; diff --git a/src/transforms/font.js b/src/transforms/font.js index 7eb1483..093c1a7 100644 --- a/src/transforms/font.js +++ b/src/transforms/font.js @@ -1,106 +1,71 @@ -/* eslint-disable no-param-reassign */ -const PARSE_STYLE_WEIGHT_VARIANT = 0; -const PARSE_SIZE = 1; -const PARSE_MAYBE_LINE_HEIGHT = 2; -const PARSE_LINE_HEIGHT = 3; -const PARSE_FONT_FAMILY = 4; -const PARSE_FINISHED = 5; +const { regExpToken, tokens } = require('../tokenTypes'); -const styles = ['italic']; -const weights = ['bold']; -const numericWeights = [100, 200, 300, 400, 500, 600, 700, 800, 900]; -const variants = ['small-caps']; +const { SPACE, LENGTH, NUMBER, SLASH, WORD, STRING } = tokens; +const NORMAL = regExpToken(/^(normal)$/); +const STYLE = regExpToken(/^(italic)$/); +const WEIGHT = regExpToken(/^([1-9]00|bold)$/); +const VARIANT = regExpToken(/^(small-caps)$/); -module.exports = (root) => { - const { nodes } = root.first; +const defaultFontStyle = 'normal'; +const defaultFontWeight = 'normal'; +const defaultFontVariant = []; - const values = nodes.reduce((accum, node) => { - if (accum.parseMode === PARSE_STYLE_WEIGHT_VARIANT) { - let didMatchStyleWeightVariant = true; - const { type, value } = node; +module.exports = (tokenStream) => { + let fontStyle; + let fontWeight; + let fontVariant; + // let fontSize; + let lineHeight; + let fontFamily; - if (type === 'word' && value === 'normal') { - /* pass */ - } else if (accum.style === undefined && type === 'word' && styles.indexOf(value) !== -1) { - accum.style = value; - } else if (accum.weight === undefined && type === 'number' && numericWeights.indexOf(Number(value)) !== -1) { - accum.weight = String(value); - } else if (accum.weight === undefined && type === 'word' && weights.indexOf(value) !== -1) { - accum.weight = value; - } else if (accum.variant === undefined && type === 'word' && variants.indexOf(value) !== -1) { - accum.variant = [value]; - } else { - didMatchStyleWeightVariant = false; - } - - if (didMatchStyleWeightVariant) { - accum.numStyleWeightVariantMatched += 1; - if (accum.numStyleWeightVariantMatched === 3) accum.parseMode = PARSE_SIZE; - return accum; - } - - accum.parseMode = PARSE_SIZE; // fallthrough + let numStyleWeightVariantMatched = 0; + while (numStyleWeightVariantMatched < 3 && tokenStream.hasTokens()) { + if (tokenStream.match(NORMAL)) { + /* pass */ + } else if (fontStyle === undefined && tokenStream.match(STYLE)) { + fontStyle = tokenStream.lastValue; + } else if (fontWeight === undefined && tokenStream.match(WEIGHT)) { + fontWeight = tokenStream.lastValue; + } else if (fontVariant === undefined && tokenStream.match(VARIANT)) { + fontVariant = [tokenStream.lastValue]; + } else { + break; } - if (accum.parseMode === PARSE_SIZE) { - if (accum.size === undefined && node.type === 'number') { - accum.size = Number(node.value); - accum.parseMode = PARSE_MAYBE_LINE_HEIGHT; - return accum; - } - } + tokenStream.expect(SPACE); + numStyleWeightVariantMatched += 1; + } - if (accum.parseMode === PARSE_MAYBE_LINE_HEIGHT) { - if (node.type === 'operator' && node.value === '/') { - accum.parseMode = PARSE_LINE_HEIGHT; - return accum; - } - accum.parseMode = PARSE_FONT_FAMILY; // fallthrough - } + const fontSize = tokenStream.expect(LENGTH); - if (accum.parseMode === PARSE_LINE_HEIGHT) { - if (accum.lineHeight === undefined && node.type === 'number') { - accum.lineHeight = Number(node.value); - accum.parseMode = PARSE_FONT_FAMILY; - return accum; - } + if (tokenStream.match(SLASH)) { + if (tokenStream.match(NUMBER)) { + lineHeight = fontSize * tokenStream.lastValue; + } else { + lineHeight = tokenStream.expect(LENGTH); } + } - if (accum.parseMode === PARSE_FONT_FAMILY) { - if (accum.family === undefined && node.type === 'string') { - accum.family = node.value; - accum.parseMode = PARSE_FINISHED; - return accum; - } else if (node.type === 'word') { - accum.family = `${(accum.family || '')} ${node.value}`; - return accum; - } - } + tokenStream.expect(SPACE); - throw new Error(`Unexpected value: ${node}`); - }, { - numStyleWeightVariantMatched: 0, - parseMode: PARSE_STYLE_WEIGHT_VARIANT, - style: undefined, - weight: undefined, - variant: undefined, - size: undefined, - lineHeight: undefined, - family: undefined, - }); + if (tokenStream.match(STRING)) { + fontFamily = tokenStream.lastValue; + } else { + fontFamily = tokenStream.expect(WORD); + while (tokenStream.hasTokens()) { + const nextWord = tokenStream.expect(WORD); + fontFamily += ` ${nextWord}`; + } + } - const { - style: fontStyle = 'normal', - weight: fontWeight = 'normal', - variant: fontVariant = [], - size: fontSize, - family: fontFamily, - } = values; + tokenStream.expectEmpty(); - if (fontSize === undefined || fontFamily === undefined) throw new Error('Unexpected error'); + if (fontStyle === undefined) fontStyle = defaultFontStyle; + if (fontWeight === undefined) fontWeight = defaultFontWeight; + if (fontVariant === undefined) fontVariant = defaultFontVariant; const out = { fontStyle, fontWeight, fontVariant, fontSize, fontFamily }; - if (values.lineHeight !== undefined) out.lineHeight = values.lineHeight; + if (lineHeight !== undefined) out.lineHeight = lineHeight; return { $merge: out }; }; diff --git a/src/transforms/index.js b/src/transforms/index.js index 7b1e7b6..288e01f 100644 --- a/src/transforms/index.js +++ b/src/transforms/index.js @@ -1,3 +1,4 @@ +const { tokens } = require('../tokenTypes'); const border = require('./border'); const flex = require('./flex'); const flexFlow = require('./flexFlow'); @@ -5,7 +6,9 @@ const font = require('./font'); const transform = require('./transform'); const { directionFactory, shadowOffsetFactory } = require('./util'); -const background = root => ({ $merge: { backgroundColor: String(root) } }); +const { WORD, COLOR } = tokens; + +const background = tokenStream => ({ $merge: { backgroundColor: tokenStream.match(COLOR) } }); const borderColor = directionFactory({ type: 'word', prefix: 'border', suffix: 'Color' }); const borderRadius = directionFactory({ directions: ['TopRight', 'BottomRight', 'BottomLeft', 'TopLeft'], @@ -15,29 +18,11 @@ const borderRadius = directionFactory({ const borderWidth = directionFactory({ prefix: 'border', suffix: 'Width' }); const margin = directionFactory({ prefix: 'margin' }); const padding = directionFactory({ prefix: 'padding' }); -const fontVariant = root => root.first.nodes.map(String); -const fontWeight = root => String(root); -const shadowOffset = shadowOffsetFactory('textShadowOffset'); +const fontVariant = tokenStream => [tokenStream.match(WORD)]; +const fontWeight = tokenStream => tokenStream.match(WORD); +const shadowOffset = shadowOffsetFactory(); const textShadowOffset = shadowOffsetFactory(); -// const transforms = [ -// 'background', -// 'border', -// 'borderColor', -// 'borderRadius', -// 'borderWidth', -// 'flex', -// 'flexFlow', -// 'font', -// 'fontVariant', -// 'fontWeight', -// 'margin', -// 'padding', -// 'shadowOffset', -// 'textShadowOffset', -// 'transform', -// ]; - module.exports = { background, border, diff --git a/src/transforms/transform.js b/src/transforms/transform.js index e0bc1ec..6e0ff24 100644 --- a/src/transforms/transform.js +++ b/src/transforms/transform.js @@ -1,37 +1,49 @@ -const { assertUptoNValuesOfType } = require('./util'); - -const singleNumber = nodes => Number(nodes[1].value); -const singleAngle = nodes => String(nodes[1]); -const xyTransformFactory = transform => (key, valueIfOmitted) => (nodes) => { - const [ - /* paren */, - xValue, - /* comma */, - yValue, - ] = nodes; - - if (xValue.type !== 'number' || (yValue && yValue.type !== 'number')) { - throw new Error('Expected values to be numbers'); - } +const { tokens } = require('../tokenTypes'); + +const { SPACE, COMMA, LENGTH, NUMBER, ANGLE } = tokens; - const x = transform(xValue); +const oneOfType = tokenType => (functionStream) => { + const value = functionStream.expect(tokenType); + functionStream.expectEmpty(); + return value; +}; - if (valueIfOmitted === undefined && yValue === undefined) return x; +const singleNumber = oneOfType(NUMBER); +const singleLength = oneOfType(LENGTH); +const singleAngle = oneOfType(ANGLE); +const xyTransformFactory = tokenType => (key, valueIfOmitted) => (functionStream) => { + const x = functionStream.expect(tokenType); + + let y; + if (functionStream.hasTokens()) { + functionStream.match(SPACE); // optional space + functionStream.expect(COMMA); + functionStream.match(SPACE); // optional space + y = functionStream.expect(tokenType); + } else if (valueIfOmitted !== undefined) { + y = valueIfOmitted; + } else { + // Assumption, if x === y, then we can omit XY + // I.e. scale(5) => [{ scale: 5 }] rather than [{ scaleX: 5 }, { scaleY: 5 }] + return x; + } + + functionStream.expectEmpty(); - const y = yValue !== undefined ? transform(yValue) : valueIfOmitted; return [{ [`${key}Y`]: y }, { [`${key}X`]: x }]; }; -const xyNumber = xyTransformFactory(node => Number(node.value)); -const xyAngle = xyTransformFactory(node => String(node).trim()); +const xyNumber = xyTransformFactory(NUMBER); +const xyLength = xyTransformFactory(LENGTH); +const xyAngle = xyTransformFactory(ANGLE); const partTransforms = { perspective: singleNumber, scale: xyNumber('scale'), scaleX: singleNumber, scaleY: singleNumber, - translate: xyNumber('translate', 0), - translateX: singleNumber, - translateY: singleNumber, + translate: xyLength('translate', 0), + translateX: singleLength, + translateY: singleLength, rotate: singleAngle, rotateX: singleAngle, rotateY: singleAngle, @@ -41,20 +53,23 @@ const partTransforms = { skew: xyAngle('skew', '0deg'), }; -module.exports = (root) => { - const { nodes } = root.first; - assertUptoNValuesOfType(Infinity, 'func', nodes); +module.exports = (tokenStream) => { + let transforms = []; - const transforms = nodes.reduce((accum, node) => { - if (!(node.value in partTransforms)) throw new Error(`Unrecognised transform: ${node.value}`); + let didParseFirst = false; + while (tokenStream.hasTokens()) { + if (didParseFirst) tokenStream.expect(SPACE); - let transformedValues = partTransforms[node.value](node.nodes); + const functionStream = tokenStream.expectFunction(); + const transformName = functionStream.parent.value; + let transformedValues = partTransforms[transformName](functionStream); if (!Array.isArray(transformedValues)) { - transformedValues = [{ [node.value]: transformedValues }]; + transformedValues = [{ [transformName]: transformedValues }]; } + transforms = transformedValues.concat(transforms); - return transformedValues.concat(accum); - }, []); + didParseFirst = true; + } return transforms; }; diff --git a/src/transforms/util.js b/src/transforms/util.js index 92f0235..c7d3577 100644 --- a/src/transforms/util.js +++ b/src/transforms/util.js @@ -1,22 +1,24 @@ -const assertUptoNValuesOfType = (n, type, nodes) => { - nodes.forEach((value) => { - if (value.type !== type) throw new Error(`Expected all values to be of type ${type}`); - }); - if (nodes.length > 4) throw new Error('Expected no more than four values'); - if (nodes.length === 0) throw new Error('Expected some values'); -}; -module.exports.assertUptoNValuesOfType = assertUptoNValuesOfType; +const { tokens } = require('../tokenTypes'); + +const { LENGTH, SPACE } = tokens; module.exports.directionFactory = ({ - type = 'number', + types = [LENGTH], directions = ['Top', 'Right', 'Bottom', 'Left'], prefix = '', suffix = '', -}) => (root) => { - const { nodes } = root.first; - assertUptoNValuesOfType(4, type, nodes); - let values = nodes.map(node => node.value); - if (type === 'number') values = values.map(Number); +}) => (tokenStream) => { + const values = []; + + values.push(tokenStream.expect(...types)); + + while (values.length < 4 && tokenStream.hasTokens()) { + tokenStream.expect(SPACE); + values.push(tokenStream.expect(...types)); + } + + tokenStream.expectEmpty(); + const [top, right = top, bottom = top, left = right] = values; const keyFor = n => `${prefix}${directions[n]}${suffix}`; @@ -31,9 +33,11 @@ module.exports.directionFactory = ({ return { $merge: output }; }; -module.exports.shadowOffsetFactory = () => (root) => { - const { nodes } = root.first; - assertUptoNValuesOfType(2, 'number', nodes); - const [width, height = width] = nodes.map(node => Number(node.value)); +module.exports.shadowOffsetFactory = () => (tokenStream) => { + const width = tokenStream.expect(LENGTH); + const height = tokenStream.match(SPACE) + ? tokenStream.expect(LENGTH) + : width; + tokenStream.expectEmpty(); return { width, height }; }; From cd87b38672f4b7909ce6cf53f745b96646d0986f Mon Sep 17 00:00:00 2001 From: Jacob Parker Date: Mon, 23 Jan 2017 11:57:14 +0000 Subject: [PATCH 3/6] Cleanup --- README.md | 24 +++++++++++------------- src/index.js | 30 +++++++++++++++++------------- src/index.test.js | 30 +++++++++++++++++++----------- src/tokenTypes.js | 1 + src/transforms/transform.js | 2 -- 5 files changed, 48 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 6a5bf84..7c23449 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ Converts CSS text to a React Native stylesheet object. ```css -font-size: 18; -line-height: 24; +font-size: 18px; +line-height: 24px; color: red; ``` @@ -21,9 +21,9 @@ Converts all number-like values to numbers, and string-like to strings. Automatically converts indirect values to their React Native equivalents. ```css -text-shadow-offset: 10 5; +text-shadow-offset: 10px 5px; font-variant: small-caps; -transform: translate(10, 5) scale(5); +transform: translate(10px, 5px) scale(5); ``` ```js @@ -42,8 +42,8 @@ transform: translate(10, 5) scale(5); Also allows shorthand values. ```css -font: bold 14/16 "Helvetica"; -margin: 5 7 2; +font: bold 14px/16px "Helvetica"; +margin: 5px 7px 2px; ``` ```js @@ -67,8 +67,6 @@ Shorthands will only accept values that are supported in React, so `background` `border{Top,Right,Bottom,Left}` shorthands are not supported, because `borderStyle` cannot be applied to individual border sides. -`flex` does not support putting `flexBasis` before `flexGrow`. The supported syntax is `flex: `. - # API The API is mostly for implementors. However, the main API may be useful for non-impmentors. The main API is, @@ -78,9 +76,9 @@ import transform from 'css-to-react-native'; // or const transform = require('css-to-react-native').default; transform([ - ['font', 'bold 14/16 "Helvetica"'], - ['margin', '5 7 2'], - ['border-left-width', '5'], + ['font', 'bold 14px/16px "Helvetica"'], + ['margin', '5px 7px 2px'], + ['border-left-width', '5px'], ]); // => { fontFamily: 'Helvetica', ... } ``` @@ -90,13 +88,13 @@ For implementors, there is also, import { getPropertyName, getStylesForProperty } from 'css-to-react-native'; getPropertyName('border-width'); // => 'borderWidth' -getStylesForProperty('borderWidth', '1 0 2 0'); // => { borderTopWidth: 1, ... } +getStylesForProperty('borderWidth', '1px 0px 2px 0px'); // => { borderTopWidth: 1, ... } ``` Should you wish to opt-out of transforming certain shorthands, an array of property names in camelCase can be passed as a second argument to `transform`. ```js -transform([['border-radius', '50']], ['borderRadius']); +transform([['border-radius', '50px']], ['borderRadius']); // { borderRadius: 50 } rather than { borderTopLeft: ... } ``` diff --git a/src/index.js b/src/index.js index abc0c84..566b73d 100644 --- a/src/index.js +++ b/src/index.js @@ -4,24 +4,28 @@ const camelizeStyleName = require('fbjs/lib/camelizeStyleName'); const transforms = require('./transforms'); const TokenStream = require('./TokenStream'); -const transformRawValue = input => ( - (input !== '' && !isNaN(input)) - ? Number(input) - : input -); +// Note if this is wrong, you'll need to change tokenTypes.js too +const numberOrLengthRe = /^([+-]?(?:\d*\.)?\d+(?:[Ee][+-]?\d+)?)(?:px)?$/; -export const parseProp = (propName, value) => { - const ast = parse(value).nodes; - const tokenStream = new TokenStream(ast); - return transforms[propName](tokenStream); +// Undocumented export +export const transformRawValue = (input) => { + const value = input.trim().match(numberOrLengthRe); + return value ? Number(value[1]) : input; }; export const getStylesForProperty = (propName, inputValue, allowShorthand) => { - const value = inputValue.trim(); + // Undocumented: allow ast to be passed in + let propValue; - const propValue = (allowShorthand && (propName in transforms)) - ? parseProp(propName, value) - : transformRawValue(value); + const isRawValue = (allowShorthand === false) || !(propName in transforms); + if (isRawValue) { + const value = typeof inputValue === 'string' ? inputValue : parse.stringify(inputValue); + propValue = transformRawValue(value); + } else { + const ast = typeof inputValue === 'string' ? parse(inputValue.trim()) : inputValue; + const tokenStream = new TokenStream(ast.nodes); + propValue = transforms[propName](tokenStream); + } return (propValue && propValue.$merge) ? propValue.$merge diff --git a/src/index.test.js b/src/index.test.js index 3168926..37c059e 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -1,5 +1,5 @@ /* global jest it, expect */ -import transformCss, { parseProp } from '.'; +import transformCss, { getStylesForProperty } from '.'; const runTest = (inputCss, expectedStyles) => { const actualStyles = transformCss(inputCss); @@ -13,17 +13,25 @@ it('transforms numbers', () => runTest([ ['bottom', '0'], ], { top: 0, left: 0, right: 0, bottom: 0 })); +it('allows pixels in unspecialized transform', () => runTest([ + ['top', '0px'], +], { top: 0 })); + +it('allows percent in unspecialized transform', () => runTest([ + ['top', '0%'], +], { top: '0%' })); + it('allows decimal values', () => { - expect(parseProp('margin', '0.5px').$merge.marginTop).toBe(0.5); - expect(parseProp('margin', '1.5px').$merge.marginTop).toBe(1.5); - expect(parseProp('margin', '10.5px').$merge.marginTop).toBe(10.5); - expect(parseProp('margin', '100.5px').$merge.marginTop).toBe(100.5); - expect(parseProp('margin', '-0.5px').$merge.marginTop).toBe(-0.5); - expect(parseProp('margin', '-1.5px').$merge.marginTop).toBe(-1.5); - expect(parseProp('margin', '-10.5px').$merge.marginTop).toBe(-10.5); - expect(parseProp('margin', '-100.5px').$merge.marginTop).toBe(-100.5); - expect(parseProp('margin', '.5px').$merge.marginTop).toBe(0.5); - expect(parseProp('margin', '-.5px').$merge.marginTop).toBe(-0.5); + expect(getStylesForProperty('margin', '0.5px').marginTop).toBe(0.5); + expect(getStylesForProperty('margin', '1.5px').marginTop).toBe(1.5); + expect(getStylesForProperty('margin', '10.5px').marginTop).toBe(10.5); + expect(getStylesForProperty('margin', '100.5px').marginTop).toBe(100.5); + expect(getStylesForProperty('margin', '-0.5px').marginTop).toBe(-0.5); + expect(getStylesForProperty('margin', '-1.5px').marginTop).toBe(-1.5); + expect(getStylesForProperty('margin', '-10.5px').marginTop).toBe(-10.5); + expect(getStylesForProperty('margin', '-100.5px').marginTop).toBe(-100.5); + expect(getStylesForProperty('margin', '.5px').marginTop).toBe(0.5); + expect(getStylesForProperty('margin', '-.5px').marginTop).toBe(-0.5); }); it('allows decimal values in transformed values', () => runTest([ diff --git a/src/tokenTypes.js b/src/tokenTypes.js index 6bbee75..100d67d 100644 --- a/src/tokenTypes.js +++ b/src/tokenTypes.js @@ -14,6 +14,7 @@ const matchColor = (node) => { }; const noneRe = /^(none)$/; +// Note if these are wrong, you'll need to change index.js too const numberRe = /^([+-]?(?:\d*\.)?\d+(?:[Ee][+-]?\d+)?)$/; const lengthRe = /^([+-]?(?:\d*\.)?\d+(?:[Ee][+-]?\d+)?)px$/; const angleRe = /^([+-]?(?:\d*\.)?\d+(?:[Ee][+-]?\d+)?(?:deg|rad))$/; diff --git a/src/transforms/transform.js b/src/transforms/transform.js index 6e0ff24..5b434c7 100644 --- a/src/transforms/transform.js +++ b/src/transforms/transform.js @@ -16,9 +16,7 @@ const xyTransformFactory = tokenType => (key, valueIfOmitted) => (functionStream let y; if (functionStream.hasTokens()) { - functionStream.match(SPACE); // optional space functionStream.expect(COMMA); - functionStream.match(SPACE); // optional space y = functionStream.expect(tokenType); } else if (valueIfOmitted !== undefined) { y = valueIfOmitted; From 1aef9df9dd7a71f5600318e34b4dce7b1cb51a16 Mon Sep 17 00:00:00 2001 From: Jacob Parker Date: Mon, 23 Jan 2017 12:17:59 +0000 Subject: [PATCH 4/6] Allow percent values, cleanup --- src/index.test.js | 9 ++++++++ src/transforms/border.js | 40 ------------------------------------ src/transforms/flex.js | 2 ++ src/transforms/flexFlow.js | 34 ------------------------------ src/transforms/index.js | 30 +++++++++++++++++++++++---- src/transforms/util.js | 42 ++++++++++++++++++++++++++++++++++---- 6 files changed, 75 insertions(+), 82 deletions(-) delete mode 100644 src/transforms/border.js delete mode 100644 src/transforms/flexFlow.js diff --git a/src/index.test.js b/src/index.test.js index 37c059e..5f9e171 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -52,6 +52,15 @@ it('allows negative values in transformed values', () => runTest([ borderBottomLeftRadius: -1.5, })); +it('allows percent values in transformed values', () => runTest([ + ['margin', '10%'], +], { + marginTop: '10%', + marginRight: '10%', + marginBottom: '10%', + marginLeft: '10%', +})); + it('transforms strings', () => runTest([ ['color', 'red'], ], { color: 'red' })); diff --git a/src/transforms/border.js b/src/transforms/border.js deleted file mode 100644 index c7528d3..0000000 --- a/src/transforms/border.js +++ /dev/null @@ -1,40 +0,0 @@ -const { regExpToken, tokens } = require('../tokenTypes'); - -const { SPACE, COLOR, LENGTH } = tokens; -const BORDER_STYLE = regExpToken(/^(solid|dashed|dotted)$/); - -/* eslint-disable no-param-reassign */ -const defaultWidth = 1; -const defaultStyle = 'solid'; -const defaultColor = 'black'; - -module.exports = (tokenStream) => { - let borderWidth; - let borderColor; - let borderStyle; - - let numParsed = 0; - while (numParsed < 3 && tokenStream.hasTokens()) { - if (numParsed) tokenStream.expect(SPACE); - - if (borderWidth === undefined && tokenStream.match(LENGTH)) { - borderWidth = tokenStream.lastValue; - } else if (borderColor === undefined && tokenStream.match(COLOR)) { - borderColor = tokenStream.lastValue; - } else if (borderStyle === undefined && tokenStream.match(BORDER_STYLE)) { - borderStyle = tokenStream.lastValue; - } else { - tokenStream.throw(); - } - - numParsed += 1; - } - - tokenStream.expectEmpty(); - - if (borderWidth === undefined) borderWidth = defaultWidth; - if (borderColor === undefined) borderColor = defaultColor; - if (borderStyle === undefined) borderStyle = defaultStyle; - - return { $merge: { borderWidth, borderStyle, borderColor } }; -}; diff --git a/src/transforms/flex.js b/src/transforms/flex.js index ff48a4e..035b521 100644 --- a/src/transforms/flex.js +++ b/src/transforms/flex.js @@ -29,6 +29,8 @@ module.exports = (tokenStream) => { } } else if (flexBasis === undefined && tokenStream.match(LENGTH)) { flexBasis = tokenStream.lastValue; + } else { + tokenStream.throw(); } partsParsed += 1; diff --git a/src/transforms/flexFlow.js b/src/transforms/flexFlow.js deleted file mode 100644 index 52c8bb7..0000000 --- a/src/transforms/flexFlow.js +++ /dev/null @@ -1,34 +0,0 @@ -/* eslint-disable no-param-reassign */ -const { tokens, regExpToken } = require('../tokenTypes'); - -const { SPACE } = tokens; -const WRAP = regExpToken(/(nowrap|wrap|wrap-reverse)/); -const DIRECTION = regExpToken(/(row|row-reverse|column|column-reverse)/); - -const defaultFlexWrap = 'nowrap'; -const defaultFlexDirection = 'row'; - -module.exports = (tokenStream) => { - let flexWrap; - let flexDirection; - - let numParsed = 0; - while (numParsed < 2 && tokenStream.hasTokens()) { - if (numParsed) tokenStream.expect(SPACE); - - if (flexWrap === undefined && tokenStream.match(WRAP)) { - flexWrap = tokenStream.lastValue; - } else if (flexDirection === undefined && tokenStream.match(DIRECTION)) { - flexDirection = tokenStream.lastValue; - } - - numParsed += 1; - } - - tokenStream.expectEmpty(); - - if (flexWrap === undefined) flexWrap = defaultFlexWrap; - if (flexDirection === undefined) flexDirection = defaultFlexDirection; - - return { $merge: { flexWrap, flexDirection } }; -}; diff --git a/src/transforms/index.js b/src/transforms/index.js index 288e01f..35e2afc 100644 --- a/src/transforms/index.js +++ b/src/transforms/index.js @@ -1,14 +1,26 @@ -const { tokens } = require('../tokenTypes'); -const border = require('./border'); +const { regExpToken, tokens } = require('../tokenTypes'); const flex = require('./flex'); -const flexFlow = require('./flexFlow'); const font = require('./font'); const transform = require('./transform'); -const { directionFactory, shadowOffsetFactory } = require('./util'); +const { directionFactory, anyOrderFactory, shadowOffsetFactory } = require('./util'); const { WORD, COLOR } = tokens; const background = tokenStream => ({ $merge: { backgroundColor: tokenStream.match(COLOR) } }); +const border = anyOrderFactory({ + borderWidth: { + token: tokens.LENGTH, + default: 1, + }, + borderColor: { + token: COLOR, + default: 'black', + }, + borderStyle: { + token: regExpToken(/^(solid|dashed|dotted)$/), + default: 'solid', + }, +}); const borderColor = directionFactory({ type: 'word', prefix: 'border', suffix: 'Color' }); const borderRadius = directionFactory({ directions: ['TopRight', 'BottomRight', 'BottomLeft', 'TopLeft'], @@ -18,6 +30,16 @@ const borderRadius = directionFactory({ const borderWidth = directionFactory({ prefix: 'border', suffix: 'Width' }); const margin = directionFactory({ prefix: 'margin' }); const padding = directionFactory({ prefix: 'padding' }); +const flexFlow = anyOrderFactory({ + flexWrap: { + token: regExpToken(/(nowrap|wrap|wrap-reverse)/), + default: 'nowrap', + }, + flexDirection: { + token: regExpToken(/(row|row-reverse|column|column-reverse)/), + default: 'row', + }, +}); const fontVariant = tokenStream => [tokenStream.match(WORD)]; const fontWeight = tokenStream => tokenStream.match(WORD); const shadowOffset = shadowOffsetFactory(); diff --git a/src/transforms/util.js b/src/transforms/util.js index c7d3577..152a859 100644 --- a/src/transforms/util.js +++ b/src/transforms/util.js @@ -1,20 +1,20 @@ const { tokens } = require('../tokenTypes'); -const { LENGTH, SPACE } = tokens; +const { LENGTH, PERCENT, SPACE } = tokens; module.exports.directionFactory = ({ - types = [LENGTH], directions = ['Top', 'Right', 'Bottom', 'Left'], prefix = '', suffix = '', }) => (tokenStream) => { const values = []; - values.push(tokenStream.expect(...types)); + // borderWidth doesn't currently allow a percent value, but may do in the future + values.push(tokenStream.expect(LENGTH, PERCENT)); while (values.length < 4 && tokenStream.hasTokens()) { tokenStream.expect(SPACE); - values.push(tokenStream.expect(...types)); + values.push(tokenStream.expect(LENGTH, PERCENT)); } tokenStream.expectEmpty(); @@ -33,6 +33,40 @@ module.exports.directionFactory = ({ return { $merge: output }; }; +module.exports.anyOrderFactory = (properties, delim = SPACE) => (tokenStream) => { + const propertyNames = Object.keys(properties); + const values = propertyNames.reduce((accum, propertyName) => { + accum[propertyName] === undefined; // eslint-disable-line + return accum; + }, {}); + + let numParsed = 0; + while (numParsed < propertyNames.length && tokenStream.hasTokens()) { + if (numParsed) tokenStream.expect(delim); + + let didMatch = false; + for (const propertyName of propertyNames) { // eslint-disable-line + if (values[propertyName] === undefined && tokenStream.match(properties[propertyName].token)) { + values[propertyName] = tokenStream.lastValue; + didMatch = true; + break; + } + } + + if (!didMatch) tokenStream.throw(); + + numParsed += 1; + } + + tokenStream.expectEmpty(); + + propertyNames.forEach((propertyName) => { + if (values[propertyName] === undefined) values[propertyName] = properties[propertyName].default; + }); + + return { $merge: values }; +}; + module.exports.shadowOffsetFactory = () => (tokenStream) => { const width = tokenStream.expect(LENGTH); const height = tokenStream.match(SPACE) From 8ff1ccf9af4ccfbdb4a41752ac9c21f08431ab9e Mon Sep 17 00:00:00 2001 From: Jacob Parker Date: Mon, 23 Jan 2017 12:19:12 +0000 Subject: [PATCH 5/6] readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 7c23449..b9f4833 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,8 @@ transform([['border-radius', '50px']], ['borderRadius']); // { borderRadius: 50 } rather than { borderTopLeft: ... } ``` +This can also be done by passing a third argument, `false` to `getStylesForProperty`. + ## License Licensed under the MIT License, Copyright © 2016 Jacob Parker and Maximilian Stoiber. From 83d470bf82a394302ed9513a5ba706f25eb77f46 Mon Sep 17 00:00:00 2001 From: Jacob Parker Date: Mon, 23 Jan 2017 12:44:56 +0000 Subject: [PATCH 6/6] Allow omitting quotes on font family --- src/index.test.js | 51 ++++++++++++++++++++++++++++++++++++ src/tokenTypes.js | 13 ++++++++- src/transforms/font.js | 17 +++--------- src/transforms/fontFamily.js | 22 ++++++++++++++++ src/transforms/index.js | 8 +++--- 5 files changed, 94 insertions(+), 17 deletions(-) create mode 100644 src/transforms/fontFamily.js diff --git a/src/index.test.js b/src/index.test.js index 5f9e171..9654f58 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -320,6 +320,57 @@ it('allows line height as multiple', () => runTest([ lineHeight: 24, })); +it('transforms font without quotes', () => runTest([ + ['font', 'bold italic small-caps 16px/18px Helvetica Neue'], +], { + fontFamily: 'Helvetica Neue', + fontSize: 16, + fontWeight: 'bold', + fontStyle: 'italic', + fontVariant: ['small-caps'], + lineHeight: 18, +})); + +it('transforms font-family with double quotes', () => runTest([ + ['font-family', '"Helvetica Neue"'], +], { + fontFamily: 'Helvetica Neue', +})); + +it('transforms font-family with single quotes', () => runTest([ + ['font-family', '\'Helvetica Neue\''], +], { + fontFamily: 'Helvetica Neue', +})); + +it('transforms font-family without quotes', () => runTest([ + ['font-family', 'Helvetica Neue'], +], { + fontFamily: 'Helvetica Neue', +})); + +it('transforms font-family with quotes with otherwise invalid values', () => runTest([ + ['font-family', '"Goudy Bookletter 1911"'], +], { + fontFamily: 'Goudy Bookletter 1911', +})); + +it('transforms font-family with quotes with escaped values', () => runTest([ + ['font-family', '"test\\A test"'], +], { + fontFamily: 'test\ntest', +})); + +it('transforms font-family with quotes with escaped quote', () => runTest([ + ['font-family', '"test\\"test"'], +], { + fontFamily: 'test"test', +})); + +it('does not transform invalid unquoted font-family', () => { + expect(() => transformCss([['font-family', 'Goudy Bookletter 1911']])).toThrow(); +}); + it('allows blacklisting shorthands', () => { const actualStyles = transformCss([['border-radius', '50']], ['borderRadius']); expect(actualStyles).toEqual({ borderRadius: 50 }); diff --git a/src/tokenTypes.js b/src/tokenTypes.js index 100d67d..3637ec6 100644 --- a/src/tokenTypes.js +++ b/src/tokenTypes.js @@ -1,6 +1,15 @@ const { stringify } = require('postcss-value-parser'); const cssColorKeywords = require('css-color-keywords'); +const matchString = (node) => { + if (node.type !== 'string') return null; + return node.value + .replace(/\\([0-9a-f]{1,6})(?:\s|$)/gi, (match, charCode) => ( + String.fromCharCode(parseInt(charCode, 16)) + )) + .replace(/\\/g, ''); +}; + const hexColorRe = /^(#(?:[0-9a-f]{3,4}){1,2})$/i; const cssFunctionNameRe = /^(rgba?|hsla?|hwb|lab|lch|gray|color)$/; @@ -14,6 +23,7 @@ const matchColor = (node) => { }; const noneRe = /^(none)$/; +const identRe = /(^-?[_a-z][_a-z0-9-]*$)/i; // Note if these are wrong, you'll need to change index.js too const numberRe = /^([+-]?(?:\d*\.)?\d+(?:[Ee][+-]?\d+)?)$/; const lengthRe = /^([+-]?(?:\d*\.)?\d+(?:[Ee][+-]?\d+)?)px$/; @@ -41,12 +51,13 @@ module.exports.tokens = { SPACE: noopToken(node => node.type === 'space'), SLASH: noopToken(node => node.type === 'div' && node.value === '/'), COMMA: noopToken(node => node.type === 'div' && node.value === ','), - STRING: valueForTypeToken('string'), WORD: valueForTypeToken('word'), NONE: regExpToken(noneRe), NUMBER: regExpToken(numberRe, Number), LENGTH: regExpToken(lengthRe, Number), ANGLE: regExpToken(angleRe), PERCENT: regExpToken(percentRe), + IDENT: regExpToken(identRe), + STRING: matchString, COLOR: matchColor, }; diff --git a/src/transforms/font.js b/src/transforms/font.js index 093c1a7..4586c35 100644 --- a/src/transforms/font.js +++ b/src/transforms/font.js @@ -1,6 +1,7 @@ +const parseFontFamily = require('./fontFamily'); const { regExpToken, tokens } = require('../tokenTypes'); -const { SPACE, LENGTH, NUMBER, SLASH, WORD, STRING } = tokens; +const { SPACE, LENGTH, NUMBER, SLASH } = tokens; const NORMAL = regExpToken(/^(normal)$/); const STYLE = regExpToken(/^(italic)$/); const WEIGHT = regExpToken(/^([1-9]00|bold)$/); @@ -16,7 +17,7 @@ module.exports = (tokenStream) => { let fontVariant; // let fontSize; let lineHeight; - let fontFamily; + // let fontFamily; let numStyleWeightVariantMatched = 0; while (numStyleWeightVariantMatched < 3 && tokenStream.hasTokens()) { @@ -48,17 +49,7 @@ module.exports = (tokenStream) => { tokenStream.expect(SPACE); - if (tokenStream.match(STRING)) { - fontFamily = tokenStream.lastValue; - } else { - fontFamily = tokenStream.expect(WORD); - while (tokenStream.hasTokens()) { - const nextWord = tokenStream.expect(WORD); - fontFamily += ` ${nextWord}`; - } - } - - tokenStream.expectEmpty(); + const fontFamily = parseFontFamily(tokenStream); if (fontStyle === undefined) fontStyle = defaultFontStyle; if (fontWeight === undefined) fontWeight = defaultFontWeight; diff --git a/src/transforms/fontFamily.js b/src/transforms/fontFamily.js new file mode 100644 index 0000000..59e1cfc --- /dev/null +++ b/src/transforms/fontFamily.js @@ -0,0 +1,22 @@ +const { tokens } = require('../tokenTypes'); + +const { SPACE, IDENT, STRING } = tokens; + +module.exports = (tokenStream) => { + let fontFamily; + + if (tokenStream.match(STRING)) { + fontFamily = tokenStream.lastValue; + } else { + fontFamily = tokenStream.expect(IDENT); + while (tokenStream.hasTokens()) { + tokenStream.expect(SPACE); + const nextIdent = tokenStream.expect(IDENT); + fontFamily += ` ${nextIdent}`; + } + } + + tokenStream.expectEmpty(); + + return fontFamily; +}; diff --git a/src/transforms/index.js b/src/transforms/index.js index 35e2afc..bef6907 100644 --- a/src/transforms/index.js +++ b/src/transforms/index.js @@ -1,10 +1,11 @@ const { regExpToken, tokens } = require('../tokenTypes'); const flex = require('./flex'); const font = require('./font'); +const fontFamily = require('./fontFamily'); const transform = require('./transform'); const { directionFactory, anyOrderFactory, shadowOffsetFactory } = require('./util'); -const { WORD, COLOR } = tokens; +const { IDENT, WORD, COLOR } = tokens; const background = tokenStream => ({ $merge: { backgroundColor: tokenStream.match(COLOR) } }); const border = anyOrderFactory({ @@ -40,8 +41,8 @@ const flexFlow = anyOrderFactory({ default: 'row', }, }); -const fontVariant = tokenStream => [tokenStream.match(WORD)]; -const fontWeight = tokenStream => tokenStream.match(WORD); +const fontVariant = tokenStream => [tokenStream.match(IDENT)]; +const fontWeight = tokenStream => tokenStream.match(WORD); // Also match numbers as strings const shadowOffset = shadowOffsetFactory(); const textShadowOffset = shadowOffsetFactory(); @@ -54,6 +55,7 @@ module.exports = { flex, flexFlow, font, + fontFamily, fontVariant, fontWeight, margin,