blob: 8a8fb1cd9eab9c868e521ba5f8b97f176eb52a00 [file] [log] [blame]
/**
@license
Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
Code distributed by Google as part of the polymer project is also
subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
*/
/*
Extremely simple css parser. Intended to be not more than what we need
and definitely not necessarily correct =).
*/
'use strict';
/** @unrestricted */
class StyleNode {
constructor() {
/** @type {number} */
this['start'] = 0;
/** @type {number} */
this['end'] = 0;
/** @type {StyleNode} */
this['previous'] = null;
/** @type {StyleNode} */
this['parent'] = null;
/** @type {Array<StyleNode>} */
this['rules'] = null;
/** @type {string} */
this['parsedCssText'] = '';
/** @type {string} */
this['cssText'] = '';
/** @type {boolean} */
this['atRule'] = false;
/** @type {number} */
this['type'] = 0;
/** @type {string} */
this['keyframesName'] = '';
/** @type {string} */
this['selector'] = '';
/** @type {string} */
this['parsedSelector'] = '';
}
}
export {StyleNode}
// given a string of css, return a simple rule tree
/**
* @param {string} text
* @return {StyleNode}
*/
export function parse(text) {
text = clean(text);
return parseCss(lex(text), text);
}
// remove stuff we don't care about that may hinder parsing
/**
* @param {string} cssText
* @return {string}
*/
function clean(cssText) {
return cssText.replace(RX.comments, '').replace(RX.port, '');
}
// super simple {...} lexer that returns a node tree
/**
* @param {string} text
* @return {StyleNode}
*/
function lex(text) {
let root = new StyleNode();
root['start'] = 0;
root['end'] = text.length
let n = root;
for (let i = 0, l = text.length; i < l; i++) {
if (text[i] === OPEN_BRACE) {
if (!n['rules']) {
n['rules'] = [];
}
let p = n;
let previous = p['rules'][p['rules'].length - 1] || null;
n = new StyleNode();
n['start'] = i + 1;
n['parent'] = p;
n['previous'] = previous;
p['rules'].push(n);
} else if (text[i] === CLOSE_BRACE) {
n['end'] = i + 1;
n = n['parent'] || root;
}
}
return root;
}
// add selectors/cssText to node tree
/**
* @param {StyleNode} node
* @param {string} text
* @return {StyleNode}
*/
function parseCss(node, text) {
let t = text.substring(node['start'], node['end'] - 1);
node['parsedCssText'] = node['cssText'] = t.trim();
if (node['parent']) {
let ss = node['previous'] ? node['previous']['end'] : node['parent']['start'];
t = text.substring(ss, node['start'] - 1);
t = _expandUnicodeEscapes(t);
t = t.replace(RX.multipleSpaces, ' ');
// TODO(sorvell): ad hoc; make selector include only after last ;
// helps with mixin syntax
t = t.substring(t.lastIndexOf(';') + 1);
let s = node['parsedSelector'] = node['selector'] = t.trim();
node['atRule'] = (s.indexOf(AT_START) === 0);
// note, support a subset of rule types...
if (node['atRule']) {
if (s.indexOf(MEDIA_START) === 0) {
node['type'] = types.MEDIA_RULE;
} else if (s.match(RX.keyframesRule)) {
node['type'] = types.KEYFRAMES_RULE;
node['keyframesName'] =
node['selector'].split(RX.multipleSpaces).pop();
}
} else {
if (s.indexOf(VAR_START) === 0) {
node['type'] = types.MIXIN_RULE;
} else {
node['type'] = types.STYLE_RULE;
}
}
}
let r$ = node['rules'];
if (r$) {
for (let i = 0, l = r$.length, r;
(i < l) && (r = r$[i]); i++) {
parseCss(r, text);
}
}
return node;
}
/**
* conversion of sort unicode escapes with spaces like `\33 ` (and longer) into
* expanded form that doesn't require trailing space `\000033`
* @param {string} s
* @return {string}
*/
function _expandUnicodeEscapes(s) {
return s.replace(/\\([0-9a-f]{1,6})\s/gi, function() {
let code = arguments[1],
repeat = 6 - code.length;
while (repeat--) {
code = '0' + code;
}
return '\\' + code;
});
}
/**
* stringify parsed css.
* @param {StyleNode} node
* @param {boolean=} preserveProperties
* @param {string=} text
* @return {string}
*/
export function stringify(node, preserveProperties, text = '') {
// calc rule cssText
let cssText = '';
if (node['cssText'] || node['rules']) {
let r$ = node['rules'];
if (r$ && !_hasMixinRules(r$)) {
for (let i = 0, l = r$.length, r;
(i < l) && (r = r$[i]); i++) {
cssText = stringify(r, preserveProperties, cssText);
}
} else {
cssText = preserveProperties ? node['cssText'] :
removeCustomProps(node['cssText']);
cssText = cssText.trim();
if (cssText) {
cssText = ' ' + cssText + '\n';
}
}
}
// emit rule if there is cssText
if (cssText) {
if (node['selector']) {
text += node['selector'] + ' ' + OPEN_BRACE + '\n';
}
text += cssText;
if (node['selector']) {
text += CLOSE_BRACE + '\n\n';
}
}
return text;
}
/**
* @param {Array<StyleNode>} rules
* @return {boolean}
*/
function _hasMixinRules(rules) {
let r = rules[0];
return Boolean(r) && Boolean(r['selector']) && r['selector'].indexOf(VAR_START) === 0;
}
/**
* @param {string} cssText
* @return {string}
*/
function removeCustomProps(cssText) {
cssText = removeCustomPropAssignment(cssText);
return removeCustomPropApply(cssText);
}
/**
* @param {string} cssText
* @return {string}
*/
export function removeCustomPropAssignment(cssText) {
return cssText
.replace(RX.customProp, '')
.replace(RX.mixinProp, '');
}
/**
* @param {string} cssText
* @return {string}
*/
function removeCustomPropApply(cssText) {
return cssText
.replace(RX.mixinApply, '')
.replace(RX.varApply, '');
}
/** @enum {number} */
export const types = {
STYLE_RULE: 1,
KEYFRAMES_RULE: 7,
MEDIA_RULE: 4,
MIXIN_RULE: 1000
}
const OPEN_BRACE = '{';
const CLOSE_BRACE = '}';
// helper regexp's
const RX = {
comments: /\/\*[^*]*\*+([^/*][^*]*\*+)*\//gim,
port: /@import[^;]*;/gim,
customProp: /(?:^[^;\-\s}]+)?--[^;{}]*?:[^{};]*?(?:[;\n]|$)/gim,
mixinProp: /(?:^[^;\-\s}]+)?--[^;{}]*?:[^{};]*?{[^}]*?}(?:[;\n]|$)?/gim,
mixinApply: /@apply\s*\(?[^);]*\)?\s*(?:[;\n]|$)?/gim,
varApply: /[^;:]*?:[^;]*?var\([^;]*\)(?:[;\n]|$)?/gim,
keyframesRule: /^@[^\s]*keyframes/,
multipleSpaces: /\s+/g
}
const VAR_START = '--';
const MEDIA_START = '@media';
const AT_START = '@';