1'use strict' 2 3var css = require('css') 4var util = require('./lib/util') 5var validateItem = require('./lib/validator').validate 6var fs = require('fs') 7var path = require('path') 8 9var SELECTOR_MATCHER = /^[\.#]?[A-Za-z0-9_\-:]+$/ 10var DESCENDANT_SELECTOR_MATCHER = /^([.#]?[A-Za-z0-9_-]+(\s+|\s*>\s*))+([.#]?[A-Za-z0-9_\-:]+)$/ 11var IMPORT_MATCHER = /(['"]([^()]+?)['"])|(['"]([^()]+?)['"]\s+(only|not)?\s?(screen)?\s?((and|or|,|not|landscape)?\s?[(]([^()])+[)]\s*)+)/g 12var LENGTH_REGEXP = /^[-+]?\d*\.?\d+(\S*)$/ 13const CARD_SELECTOR = /^[\.#][A-Za-z0-9_\-]+$/ 14const card = process.env.DEVICE_LEVEL === 'card' 15 16/** 17 * expand margin、padding、border、borderWidth、borderColor、borderStyle properties、animation 18 * 19 * @param {object} subResult 20 * @param {String} camelCasedName 21 * @param {object} ruleResult 22 */ 23function expand (subResult, camelCasedName, ruleResult) { 24 if (camelCasedName === 'border') { 25 subResult.value.forEach(function (item) { 26 if (item.type === 'Width' || item.type === 'Color' || item.type === 'Style') { 27 const spliceName = [camelCasedName + 'Top' + item.type, camelCasedName + 'Right' + item.type, camelCasedName + 28 'Bottom' + item.type, camelCasedName + 'Left' + item.type] 29 util.splitAttr(ruleResult, item.value, spliceName) 30 } 31 else { 32 ruleResult[camelCasedName + item.type] = item.value 33 } 34 }) 35 } 36 else if (['borderTop', 'borderRight', 'borderBottom', 'borderLeft'].includes(camelCasedName)) { 37 subResult.value.forEach(function (item) { 38 ruleResult[camelCasedName + item.type] = item.value 39 }) 40 } 41 else if (camelCasedName === 'margin' || camelCasedName === 'padding') { 42 const spliceName = [camelCasedName + 'Top', camelCasedName + 'Right', camelCasedName + 'Bottom', camelCasedName + 'Left'] 43 util.splitAttr(ruleResult, subResult.value, spliceName) 44 } 45 else if (camelCasedName === 'borderWidth') { 46 util.splitAttr(ruleResult, subResult.value, ['borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth']) 47 } 48 else if (camelCasedName === 'borderColor') { 49 util.splitAttr(ruleResult, subResult.value, ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor']) 50 } 51 else if (camelCasedName === 'borderStyle') { 52 util.splitAttr(ruleResult, subResult.value, ['borderTopStyle', 'borderRightStyle', 'borderBottomStyle', 'borderLeftStyle']) 53 } 54 else if (camelCasedName === 'borderRadius') { 55 util.splitAttr(ruleResult, subResult.value, ['borderBottomLeftRadius', 'borderBottomRightRadius', 'borderTopLeftRadius', 'borderTopRightRadius']) 56 } 57 else if (camelCasedName === 'gridGap') { 58 util.splitAttr(ruleResult, subResult.value, ['gridRowsGap', 'gridColumnsGap']) 59 } 60 else if (camelCasedName === 'boxShadow') { 61 subResult.value.forEach(function (item) { 62 if (item.type === 'H' || item.type === 'V' || item.type === 'Blur' || item.type === 'Spread' || 63 item.type === 'Color') { 64 util.splitAttr(ruleResult, item.value, [camelCasedName + item.type]) 65 } 66 }) 67 } 68 else if (camelCasedName === 'animation') { 69 Object.assign(ruleResult, subResult.value); 70 } 71 else { 72 // never to do 73 } 74} 75 76/** 77 * expand flex style 78 * 79 * @param {object} rule 80 * @param {Array} ruleLog 81 */ 82function flexExpand(rule, ruleLog) { 83 for (let i = 0; i < rule.declarations.length; i++) { 84 let declaration = rule.declarations[i] 85 if (declaration.property === 'flex') { 86 let values = declaration.value.split(/\s+/) 87 rule.declarations.splice(i, 1) 88 if (values.length === 1) { 89 checkFlexOne(rule, ruleLog, declaration, values, i) 90 } else if (values.length === 2) { 91 checkFlexTwo(rule, ruleLog, declaration, values, i) 92 } else if (values.length === 3) { 93 checkFlexThree(rule, ruleLog, declaration, values, i) 94 } else { 95 ruleLog.push({ 96 line: declaration.position.start.line, 97 column: declaration.position.start.column, 98 reason: 'ERROR: Value `' + declaration.value + '` of the `' + declaration.property + '` attribute is incorrect.' 99 }) 100 } 101 } 102 } 103} 104 105function getUnit(value) { 106 value = value.toString().trim() 107 let match = value.match(LENGTH_REGEXP) 108 if (match) { 109 let unit = match[1] 110 if (unit) { 111 if (unit === 'px') { 112 return "px" 113 } 114 } else { 115 return "none" 116 } 117 } 118 return null 119} 120 121function checkFlexOne(rule, ruleLog, declaration, values, i) { 122 const array = ['none', 'auto', 'initial'] 123 if (array.includes(values[0])) { 124 rule.declarations.splice(i, 0, {type: 'declaration', property: 'flex', value: values[0], position: declaration.position}) 125 } else if (getUnit(values[0]) === 'px') { 126 rule.declarations.splice(i, 0, {type: 'declaration', property: 'flex-basis', value: values[0], position: declaration.position}) 127 } else if (getUnit(values[0]) === 'none') { 128 rule.declarations.splice(i, 0, {type: 'declaration', property: 'flex-grow', value: values[0], position: declaration.position}) 129 } else { 130 ruleLog.push({ 131 line: declaration.position.start.line, 132 column: declaration.position.start.column, 133 reason: 'ERROR: Value `' + declaration.value + '` of the `' + declaration.property + '` attribute is incorrect.' + 134 'It must be a number, a number with unit `' + 'px`' + ', none, auto, or initial.' 135 }) 136 } 137} 138 139function checkFlexTwo(rule, ruleLog, declaration, values, i) { 140 if (getUnit(values[0]) === 'none') { 141 rule.declarations.splice(i, 0, {type: 'declaration', property: 'flex-grow', value: values[0], position: declaration.position}) 142 if (getUnit(values[1]) === 'px') { 143 rule.declarations.splice(i, 0, {type: 'declaration', property: 'flex-basis', value: values[1], position: declaration.position}) 144 } else if (getUnit(values[1]) === 'none') { 145 rule.declarations.splice(i, 0, {type: 'declaration', property: 'flex-shrink', value: values[1], position: declaration.position}) 146 } else { 147 ruleLog.push({ 148 line: declaration.position.start.line, 149 column: declaration.position.start.column, 150 reason: 'ERROR: Value `' + declaration.value + '` of the `' + declaration.property + '` attribute is incorrect. Value `' + 151 values[1] + '` must be a number or a number with unit `' + 'px`.' 152 }) 153 } 154 } else { 155 ruleLog.push({ 156 line: declaration.position.start.line, 157 column: declaration.position.start.column, 158 reason: 'ERROR: Value `' + declaration.value + '` of the `' + declaration.property + '` attribute is incorrect. Value `' + 159 values[0] + '` must be a number.' 160 }) 161 } 162} 163 164function checkFlexThree(rule, ruleLog, declaration, values, i) { 165 if (getUnit(values[0]) === 'none' && getUnit(values[1]) === 'none' && getUnit(values[2]) === 'px') { 166 rule.declarations.splice(i, 0, {type: 'declaration', property: 'flex-grow', value: values[0], position: declaration.position}) 167 rule.declarations.splice(i, 0, {type: 'declaration', property: 'flex-shrink', value: values[1], position: declaration.position}) 168 rule.declarations.splice(i, 0, {type: 'declaration', property: 'flex-basis', value: values[2], position: declaration.position}) 169 } else { 170 ruleLog.push({ 171 line: declaration.position.start.line, 172 column: declaration.position.start.column, 173 reason: 'ERROR: Value `' + declaration.value + '` of the `' + declaration.property + 174 '` attribute is incorrect. It must be in the format of (1, 1, 1px).' 175 }) 176 } 177} 178 179/** 180 * Parse `<style>` code to a JSON Object and log errors & warnings 181 * 182 * @param {string} code 183 * @param {function} done which will be called with 184 * - err:Error 185 * - data.jsonStyle{}: `classname.propname.value`-like object 186 * - data.log[{line, column, reason}] 187 */ 188function parse(code, done, resourcePath) { 189 var ast, err, jsonStyle = {}, log = [] 190 191 // css parse 192 ast = css.parse(code, {silent: true}) 193 194 // catch syntax error 195 if (ast.stylesheet.parsingErrors && ast.stylesheet.parsingErrors.length) { 196 err = ast.stylesheet.parsingErrors 197 err.forEach(function (error) { 198 log.push({line: error.line, column: error.column, reason: error.toString().replace('Error', 'ERROR')}) 199 }) 200 } 201 202 // walk all 203 /* istanbul ignore else */ 204 if (ast && ast.type === 'stylesheet' && ast.stylesheet && 205 ast.stylesheet.rules && ast.stylesheet.rules.length) { 206 ast.stylesheet.rules.forEach(function (rule) { 207 var type = rule.type 208 var ruleResult = {} 209 var ruleLog = [] 210 211 if (type === 'rule') { 212 if (rule.declarations && rule.declarations.length) { 213 flexExpand(rule, ruleLog) 214 215 rule.declarations.forEach(function (declaration) { 216 var subType = declaration.type 217 var name, value, line, column, subResult, camelCasedName 218 219 /* istanbul ignore if */ 220 if (subType !== 'declaration') { 221 return 222 } 223 224 name = declaration.property 225 value = declaration.value 226 227 // validate declarations and collect them to result 228 camelCasedName = util.hyphenedToCamelCase(name) 229 subResult = validateItem(camelCasedName, value) 230 231 // expand margin、padding、border、borderWidth、borderColor、borderStyle properties、animation 232 if (subResult.value && Object.values(util.SPLECIAL_ATTR).indexOf(camelCasedName) !== -1) { 233 expand(subResult, camelCasedName, ruleResult) 234 } 235 236 /* istanbul ignore else */ 237 if ((typeof subResult.value === 'number' || typeof subResult.value === 'string') 238 && !Object.values(util.SPLECIAL_ATTR).includes(camelCasedName)) { 239 ruleResult[camelCasedName] = subResult.value 240 } 241 if (subResult.log) { 242 subResult.log.line = declaration.position.start.line 243 subResult.log.column = declaration.position.start.column 244 ruleLog.push(subResult.log) 245 } 246 }) 247 248 if (card && rule.selectors.length > 1) { 249 log.push({ 250 line: rule.position.start.line, 251 column: rule.position.start.column, 252 reason: 'ERROR: The `' + rule.selectors.join(', ') + '` selector is not supported.' 253 }) 254 } else { 255 rule.selectors.forEach(function (selector) { 256 const flag = card ? selector.match(CARD_SELECTOR) : 257 selector.match(SELECTOR_MATCHER) || selector.match(DESCENDANT_SELECTOR_MATCHER) 258 if (flag) { 259 var className = selector 260 261 // handle pseudo class 262 var pseudoIndex = className.indexOf(':') 263 if (pseudoIndex > -1) { 264 var pseudoCls = className.slice(pseudoIndex) 265 className = className.slice(0, pseudoIndex) 266 var pseudoRuleResult = {} 267 Object.keys(ruleResult).forEach(function (prop) { 268 pseudoRuleResult[prop + pseudoCls] = ruleResult[prop] 269 }) 270 ruleResult = pseudoRuleResult 271 } 272 273 // merge style 274 Object.keys(ruleResult).forEach(function (prop) { 275 // handle transition 276 if (prop.indexOf('transition') === 0 && prop !== 'transition') { 277 var realProp = prop.replace('transition', '') 278 realProp = realProp[0].toLowerCase() + realProp.slice(1) 279 jsonStyle['@TRANSITION'] = jsonStyle['@TRANSITION'] || {} 280 jsonStyle['@TRANSITION'][className] = jsonStyle['@TRANSITION'][className] || {} 281 jsonStyle['@TRANSITION'][className][realProp] = ruleResult[prop] 282 } 283 284 jsonStyle[className] = jsonStyle[className] || {} 285 jsonStyle[className][prop] = ruleResult[prop] 286 }) 287 } else { 288 log.push({ 289 line: rule.position.start.line, 290 column: rule.position.start.column, 291 reason: 'ERROR: The `' + selector + '` selector is not supported.' 292 }) 293 } 294 }) 295 } 296 log = log.concat(ruleLog) 297 } 298 } 299 /* istanbul ignore else */ 300 else if (type === 'font-face') { 301 /* istanbul ignore else */ 302 if (rule.declarations && rule.declarations.length) { 303 rule.declarations.forEach(function (declaration) { 304 /* istanbul ignore if */ 305 if (declaration.type !== 'declaration') { 306 return 307 } 308 var name = util.hyphenedToCamelCase(declaration.property) 309 var value = declaration.value 310 if (name === 'fontFamily' && '\"\''.indexOf(value[0]) > -1) { // FIXME: delete leading and trailing quotes 311 value = value.slice(1, value.length - 1) 312 } 313 ruleResult[name] = value 314 }) 315 if (!jsonStyle['@FONT-FACE']) { 316 jsonStyle['@FONT-FACE'] = [] 317 } 318 jsonStyle['@FONT-FACE'].push(ruleResult) 319 } 320 } 321 else if (type === 'import') { 322 parseImport(resourcePath, rule, jsonStyle, log) 323 } 324 else if (type === 'keyframes' && !card) { 325 if (!jsonStyle['@KEYFRAMES']) { 326 jsonStyle['@KEYFRAMES'] = {} 327 } 328 var keyName = rule.name 329 jsonStyle['@KEYFRAMES'][keyName] = [] 330 if (rule.keyframes && rule.keyframes.length) { 331 if (card) { 332 log.push({ 333 line: rule.position.start.line, 334 column: rule.position.start.column, 335 reason: 'ERROR: The keyframes is not supported!' 336 }) 337 } else { 338 rule.keyframes.forEach(function (keyframe) { 339 340 var keyframeType = keyframe.type 341 342 /* istanbul ignore if */ 343 if (keyframeType !== 'keyframe') { 344 return 345 } 346 347 if (keyframe.declarations && keyframe.declarations.length) { 348 keyframe.declarations.forEach(function (declaration) { 349 var subType = declaration.type 350 var name, value, line, column, subResult, camelCasedName 351 352 /* istanbul ignore if */ 353 if (subType !== 'declaration') { 354 return 355 } 356 357 name = declaration.property 358 value = declaration.value 359 360 // validate declarations and collect them to result 361 camelCasedName = util.hyphenedToCamelCase(name) 362 subResult = validateItem(camelCasedName, value) 363 364 // expand margin、padding、border、borderWidth、borderColor、borderStyle properties 365 if (subResult.value && Object.values(util.SPLECIAL_ATTR).indexOf(camelCasedName) !== -1) { 366 expand(subResult, camelCasedName, ruleResult) 367 } 368 /* istanbul ignore else */ 369 if ((typeof subResult.value === 'number' || typeof subResult.value === 'string') 370 && !Object.values(util.SPLECIAL_ATTR).includes(camelCasedName)) { 371 ruleResult[camelCasedName] = subResult.value 372 } 373 if (subResult.log) { 374 subResult.log.line = declaration.position.start.line 375 subResult.log.column = declaration.position.start.column 376 ruleLog.push(subResult.log) 377 } 378 }) 379 } 380 381 if (keyframe.values[0] === 'from') { 382 var keyframeResult = {} 383 Object.keys(ruleResult).forEach(function (prop) { 384 keyframeResult[prop] = ruleResult[prop] 385 }) 386 keyframeResult['time'] = 0 387 jsonStyle['@KEYFRAMES'][keyName].push(keyframeResult) 388 } 389 if (keyframe.values[0] === 'to') { 390 var keyframeResult = {} 391 Object.keys(ruleResult).forEach(function (prop) { 392 keyframeResult[prop] = ruleResult[prop] 393 }) 394 keyframeResult['time'] = 100 395 jsonStyle['@KEYFRAMES'][keyName].push(keyframeResult) 396 } 397 var patt = new RegExp(/^(100|[1-9]?\d)%$/) 398 if (patt.test(keyframe.values[0])) { 399 var keyframeResult = {} 400 Object.keys(ruleResult).forEach(function (prop) { 401 keyframeResult[prop] = ruleResult[prop] 402 }) 403 keyframeResult['time'] = keyframe.values[0].replace("%", "") 404 jsonStyle['@KEYFRAMES'][keyName].push(keyframeResult) 405 } 406 }) 407 log = log.concat(ruleLog) 408 } 409 } 410 } 411 else if (type === 'media') { 412 if (!jsonStyle['@MEDIA']) { 413 jsonStyle['@MEDIA'] = [] 414 } 415 var condition = rule.media 416 var mediaObj = {} 417 mediaObj['condition'] = condition 418 419 if (rule.rules && rule.rules.length) { 420 rule.rules.forEach(function(rule) { 421 ruleResult = {} 422 if (rule.type === 'import') { 423 parseImport(resourcePath, rule, mediaObj, log) 424 } 425 if (rule.declarations && rule.declarations.length) { 426 flexExpand(rule, ruleLog) 427 rule.declarations.forEach(function (declaration) { 428 var subType = declaration.type 429 var name, value, line, column, subResult, camelCasedName 430 431 /* istanbul ignore if */ 432 if (subType !== 'declaration') { 433 return 434 } 435 436 name = declaration.property 437 value = declaration.value 438 439 // validate declarations and collect them to result 440 camelCasedName = util.hyphenedToCamelCase(name) 441 subResult = validateItem(camelCasedName, value) 442 // expand margin、padding、border、borderWidth、borderColor、borderStyle properties 443 if (subResult.value && Object.values(util.SPLECIAL_ATTR).indexOf(camelCasedName) !== -1) { 444 expand(subResult, camelCasedName, ruleResult) 445 } 446 447 /* istanbul ignore else */ 448 if ((typeof subResult.value === 'number' || typeof subResult.value === 'string') 449 && !Object.values(util.SPLECIAL_ATTR).includes(camelCasedName)) { 450 ruleResult[camelCasedName] = subResult.value 451 } 452 if (subResult.log) { 453 subResult.log.line = declaration.position.start.line 454 subResult.log.column = declaration.position.start.column 455 ruleLog.push(subResult.log) 456 } 457 }) 458 rule.selectors.forEach(function (selector) { 459 if (selector.match(SELECTOR_MATCHER) || selector.match(DESCENDANT_SELECTOR_MATCHER)) { 460 var className = selector 461 462 // handle pseudo class 463 var pseudoIndex = className.indexOf(':') 464 if (pseudoIndex > -1) { 465 var pseudoCls = className.slice(pseudoIndex) 466 className = className.slice(0, pseudoIndex) 467 var pseudoRuleResult = {} 468 Object.keys(ruleResult).forEach(function (prop) { 469 pseudoRuleResult[prop + pseudoCls] = ruleResult[prop] 470 }) 471 ruleResult = pseudoRuleResult 472 } 473 // merge style 474 Object.keys(ruleResult).forEach(function (prop) { 475 // handle transition 476 if (prop.indexOf('transition') === 0 && prop !== 'transition') { 477 var realProp = prop.replace('transition', '') 478 realProp = realProp[0].toLowerCase() + realProp.slice(1) 479 mediaObj['@TRANSITION'] = mediaObj['@TRANSITION'] || {} 480 mediaObj['@TRANSITION'][className] = mediaObj['@TRANSITION'][className] || {} 481 mediaObj['@TRANSITION'][className][realProp] = ruleResult[prop] 482 } 483 mediaObj[className] = mediaObj[className] || {} 484 mediaObj[className][prop] = ruleResult[prop] 485 }) 486 } else { 487 log.push({ 488 line: rule.position.start.line, 489 column: rule.position.start.column, 490 reason: 'ERROR: The `' + selector + '` selector is not supported.' 491 }) 492 } 493 }) 494 log = log.concat(ruleLog) 495 } 496 }) 497 } 498 jsonStyle['@MEDIA'].push(mediaObj) 499 } 500 }) 501 } 502 503 done(err, {jsonStyle: jsonStyle, log: log}) 504} 505 506function parseImport(resourcePath, rule, jsonStyle, log) { 507 if(!resourcePath) { 508 return 509 } 510 let importString = rule.import 511 let importPath 512 let mediaString = '' 513 let source = '' 514 if (importString.match(IMPORT_MATCHER)) { 515 let filePath = importString.match(/['"]([^()]+?)['"]/) 516 importPath = filePath[1] 517 mediaString = importString.replace(importPath, '').replace(/['"]/g, '') 518 } 519 if(/^(\.)|(\.\.)\//.test(importPath)) { 520 resourcePath = resourcePath.substring(0, resourcePath.lastIndexOf(path.sep) + 1); 521 importPath = path.resolve(resourcePath, importPath) 522 } 523 if (fs.existsSync(importPath)) { 524 source = fs.readFileSync(importPath).toString() 525 } else { 526 log.push({ 527 line: rule.position.start.line, 528 column: rule.position.start.column, 529 reason: 'ERROR: no such file or directory, open ' + importPath 530 }) 531 return 532 } 533 if (mediaString.length !== 0) { 534 source = '@media ' + mediaString + '{\n' + source + '\n}' 535 } 536 parse(source, (err, obj) => { 537 if (err) { 538 throw(err) 539 } else { 540 jsonStyle = Object.assign(jsonStyle, obj.jsonStyle) 541 } 542 }, importPath) 543} 544 545/** 546 * Validate a JSON Object and log errors & warnings 547 * 548 * @param {object} json 549 * @param {function} done which will be called with 550 * - err:Error 551 * - data.jsonStyle{}: `classname.propname.value`-like object 552 * - data.log[{reason}] 553 */ 554function validate(json, done) { 555 var log = [] 556 var err 557 558 try { 559 json = JSON.parse(JSON.stringify(json)) 560 } 561 catch (e) { 562 err = e 563 json = {} 564 } 565 566 Object.keys(json).forEach(function (selector) { 567 var declarations = json[selector] 568 569 Object.keys(declarations).forEach(function (name) { 570 var value = declarations[name] 571 var result = validateItem(name, value) 572 573 if (typeof result.value === 'number' || typeof result.value === 'string') { 574 declarations[name] = result.value 575 } 576 else { 577 delete declarations[name] 578 } 579 580 if (result.log) { 581 log.push(result.log) 582 } 583 }) 584 }) 585 586 done(err, { 587 jsonStyle: json, 588 log: log 589 }) 590} 591 592module.exports = { 593 parse: parse, 594 validate: validate, 595 validateItem: validateItem, 596 util: util, 597 expand: expand, 598 getUnit: getUnit, 599} 600 601