Source: autoclasscss.js

/**
 * @file AutoclassCSS - Generator CSS skeleton {@link https://github.com/tenorok/autoclassCSS}
 * @copyright 2012–2013 Artem Kurbatov, tenorok.ru
 * @license MIT license
 * @version 0.0.3
 */

(function(global) {

/**
 * Конструктор
 * @constructor
 * @name Autoclasscss
 * @param {string} [html] HTML-разметка
 * @param {Object} [options] Опции
 */
function Autoclasscss(html, options) {

    // Если переданы только опции
    if(isObject(html)) {
        options = html;
        html = '';
    }

    this.html = html || '';
    this.params = {};

    // Если переданы опции
    if(isObject(options)) {
        return setOptions.call(this, options);
    }

    // Устанавливаются стандартные опции
    return setOptions.call(this);
}

/**
 * Установить опции
 * @private
 * @param {Object} [customOptions] Опции или ничего для установления стандартных опций
 * @returns {this}
 */
function setOptions(customOptions) {

    var options = mergeOptions(customOptions);

    for(var option in options) {
        this[option].apply(this, getOptionAsArray(option, options[option]));
    }

    return this;
}

/**
 * Получить опцию в виде массива
 * Для передачи аргументов в apply
 * @private
 * @param {string} name Имя опции
 * @param {*} value Значение опции
 * @returns {Array}
 */
function getOptionAsArray(name, value) {

    if(isOptionParamCanBeArray(name, value)) {
        return [value];
    }

    return isArray(value) ? value : [value];
}

/**
 * Может ли опция принимать массив в качестве аргумента
 * Некоторым опциям надо передавать параметр в виде массива
 * @private
 * @param {string} name Имя опции
 * @param {*} value Значение опции
 * @returns {boolean}
 */
function isOptionParamCanBeArray(name, value) {
    return !!~['ignore', 'tag'].indexOf(name) && isArray(value);
}

/**
 * Объединить опции со стандартными опциями
 * @private
 * @param {Object} [customOptions] Опции
 * @returns {*}
 */
function mergeOptions(customOptions) {
    var options = getDefaultOptions();
    if(!customOptions) return options;

    for(var option in customOptions) {
        options[option] = customOptions[option];
    }

    return options;
}

/**
 * Получить стандартные опции
 * @private
 * @returns {Object}
 */
function getDefaultOptions() {
    return {
        brace: 'default',
        flat: false,
        ignore: false,
        indent: ['spaces', 4],
        inner: true,
        line: false,
        tag: false
    };
}

/**
 * Продублировать строку
 * @private
 * @param {string} string Строка
 * @param {number} count Количество дублирований
 * @returns {string}
 */
function duplicateStr(string, count) {
    return new Array(count + 1).join(string);
}

function isString(string) {
    return typeof string === 'string';
}

function isBoolean(bool) {
    return typeof bool === 'boolean';
}

function isArray(array) {
    return array instanceof Array;
}

function isObject(object) {
    return object instanceof Object;
}

function isRegexp(regexp) {
    return regexp instanceof RegExp;
}

Autoclasscss.prototype = {

    /**
     * Настройка отступов
     * @memberof Autoclasscss#
     * @param {string} type Тип отступов, принимает одно из следующих значений:
     *     "tabs" - табы
     *     "spaces" - пробелы
     * @param {number} [count=1] Количество символов в одном отступе
     * @throws {Error} Неизвестный тип отступов
     * @returns {this}
     */
    indent: function(type, count) {

        count = count || 1;

        var indents = {
                tabs: '\t',
                spaces: ' '
            },
            indentStr = indents[type];

        if(!indentStr) {
            throw new Error('Unknown indent type: ' + type);
        }

        this.params.indent = duplicateStr(indentStr, count);

        return this;
    },

    /**
     * Добавление игнорируемых классов
     * @memberof Autoclasscss#
     * @param {string|Array|boolean|RegExp} classes Класс, массив классов, регулярное выражение или false для отмены игнорирования
     * @returns {this}
     */
    ignore: function(classes) {

        // Если false
        if(isBoolean(classes) && !classes) {
            this.params.ignore = [];
            return this;
        }

        if(isRegexp(classes)) {
            this.params.ignore = classes;
            return this;
        }

        // Если в ignore не массив, а регулярное выражение
        if(!isArray(this.params.ignore)) {
            // Сброс ignore в пустой массив, чтобы не было ошибок при добавлении
            this.params.ignore = [];
        }

        if(isString(classes)) {
            this.params.ignore.push(classes);
            return this;
        }

        if(isArray(classes)) {
            this.params.ignore = this.params.ignore.concat(classes);
            return this;
        }
    },

    /**
     * Установление плоского или вложенного списка селекторов
     * @memberof Autoclasscss#
     * @param {boolean} state Плоский или не плоский список
     * @returns {this}
     */
    flat: function(state) {
        this.params.flat = state;
        return this;
    },

    /**
     * Добавлять или не добавлять отступы внутри фигурных скобок
     * @memberof Autoclasscss#
     * @param {boolean} state Добавлять или не добавлять
     * @returns {this}
     */
    inner: function(state) {
        this.params.inner = state;
        return this;
    },

    /**
     * Указывать тег в селекторе
     * @memberof Autoclasscss#
     * @param {boolean|string|Array} tag Значение опции можно передавать в разном виде, например:
     *     true|false - указывать или не указывать все теги
     *     'div' - указывать тег div
     *     ['ul', 'li'] - указывать теги ul и li
     * @returns {this}
     */
    tag: function(tag) {
        this.params.tag = isString(tag) ? [tag] : tag;
        return this;
    },

    /**
     * Способ отображения открывающей скобки
     * @memberof Autoclasscss#
     * @param {string} type Способ отображения, принимает одно из следующих значений:
     *     "default" - через пробел после селектора
     *     "newline" - на новой строке под селектором
     * @throws {Error} Неизвестный способ отображения
     * @returns {this}
     */
    brace: function(type) {

        if(!~['default', 'newline'].indexOf(type)) {
            throw new Error('Unknown brace type: ' + type);
        }

        this.params.brace = type;
        return this;
    },

    /**
     * Отбивать селекторы пустой строкой
     * @memberof Autoclasscss#
     * @param {boolean} state Отбивать или не отбивать
     * @param {number} [count=1] Количество строк для отбива
     * @returns {this}
     */
    line: function(state, count) {
        this.params.line = state ? duplicateStr('\n', count || 1) : '';
        return this;
    },

    /**
     * Установить HTML-разметку
     * @memberof Autoclasscss#
     * @param {string} html HTML-разметка
     * @returns {this}
     */
    set: function(html) {
        this.html = html;
        return this;
    },

    /**
     * Получить CSS-каркас
     * @memberof Autoclasscss#
     * @returns {string} CSS-каркас
     */
    get: function() {

        var that = this;

        /**
         * Колбек вызывается для каждого вхождения подстроки в строку
         * @private
         * @callback Autoclasscss~iterateSubstrCallback
         * @param {Object} match Информация о текущем вхождении
         */

        /**
         * Проитерироваться по всем вхождениям подстроки в строку
         * @private
         * @param {string} string Исходная строка
         * @param {RegExp} regexp Регулярное выражения для поиска подстроки
         * @param {Autoclasscss~iterateSubstrCallback} callback Колбек будет вызван для каждого вхождения
         */
        function iterateSubstr(string, regexp, callback) {

            var match;

            while((match = regexp.exec(string)) != null) {
                callback.call(this, match);
            }
        }

        /**
         * Получить информационный массив по всем открывающим тегам в HTML
         * @private
         * @param {string} html Исходный HTML
         * @returns {Array}
         */
        function searchOpenTags(html) {

            var openTagsInfo = [];

            iterateSubstr(html, /<[-A-Za-z0-9_]+/g, function(openTag) {
                openTagsInfo.push({
                    type: 'tag-open',
                    position: openTag.index,
                    name: openTag[0].substr(1)
                });
            });

            return openTagsInfo;
        }

        /**
         * Получить информационный массив по всем закрывающим тегам в HTML
         * @private
         * @param {string} html Исходный HTML
         * @returns {Array}
         */
        function searchCloseTags(html) {

            var closeTagsInfo = [];

            iterateSubstr(html, /<\//g, function(closeTag) {
                closeTagsInfo.push({
                    type: 'tag-close',
                    position: closeTag.index
                });
            });

            return closeTagsInfo;
        }

        /**
         * Получить содержимое атрибута class
         * @private
         * @param {string} classAttr Вырванный из HTML кусок с атрибутом class
         * @returns {string}
         */
        function getClassAttrContent(classAttr) {
            return classAttr.match(/('|")[\s*-A-Za-z0-9_\s*]+('|")/i)[0].replace(/\s*('|")\s*/g, '');
        }

        /**
         * Колбек вызывается для каждого класса в атрибуте class
         * @private
         * @callback Autoclasscss~iterateClassesInAttrCallback
         * @param {string} cls Текущий класс
         * @param {number} pos Порядковый номер класса в атрибуте
         */

        /**
         * Проитерироваться по классам в атрибуте class
         * @private
         * @param {string} classAttrContent Содержимое атрибута class
         * @param {Autoclasscss~iterateClassesInAttrCallback} callback Колбек будет вызван для каждого класса
         */
        function iterateClassesInAttr(classAttrContent, callback) {

            // Если атрибут класса пустой
            if(!classAttrContent) return;

            classAttrContent.replace(/\s+/g, ' ').split(' ').forEach(function(cls, pos) {
                callback.call(this, cls, pos);
            });
        }

        /**
         * Получить информационный массив по всем классам в HTML
         * @private
         * @param {string} html Исходный HTML
         * @returns {Array}
         */
        function searchClasses(html) {

            var classesInfo = [];

            // Перебор всех атрибутов class в html
            iterateSubstr(html, /\s+class\s*=\s*('|")\s*[-A-Za-z0-9_\s*]+\s*('|")/g, function(classAttr) {

                iterateClassesInAttr(getClassAttrContent(classAttr[0]), function(cls, pos) {
                    classesInfo.push({
                        type: 'class',
                        position: classAttr.index + pos, // Для сохранения последовательности классов в атрибуте
                        val: cls
                    });
                });
            });

            return classesInfo;
        }

        /**
         * Узнать является ли тег одиночным
         * @private
         * @param {string} tag Имя тега
         * @returns {boolean}
         */
        function isSingleTag(tag) {
            return !!~[
                '!doctype', 'area', 'base', 'br', 'col', 'command', 'embed', 'frame',
                'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'wbr'
            ].indexOf(tag);
        }

        /**
         * Является ли класс игнорируемым
         * @private
         * @param {string} cls Имя класса
         * @returns {boolean}
         */
        function isIgnoringClass(cls) {

            if(isArray(that.params.ignore)) {
                return ~that.params.ignore.indexOf(cls);
            }

            // Иначе в ignore регулярное выражение
            return that.params.ignore.test(cls);
        }

        /**
         * Получить массив тегов с их классами
         * @private
         * @param {Array} htmlStructureInfo Информационный массив по HTML-структуре
         * @returns {Array}
         */
        function putClassesIntoTags(htmlStructureInfo) {

            var tags = [];

            htmlStructureInfo.forEach(function(element) {

                switch(element.type) {

                    case 'tag-open':
                        tags.push({
                            type: element.type,
                            name: element.name,
                            single: isSingleTag(element.name),
                            classes: []
                        });
                        break;

                    case 'class':
                        isIgnoringClass(element.val) || tags[tags.length - 1].classes.push(element.val);
                        break;

                    case 'tag-close':
                        tags.push({
                            type: element.type
                        });
                }
            });

            return tags;
        }

        /**
         * Получить плоский массив классов с указанием их уровня вложенности
         * @private
         * @param {Array} tags Массив тегов с их классами
         * @returns {Array}
         */
        function getClassLevels(tags) {

            var classes = [],
                tree = [], // Для контроля уровня вложенности
                exist = []; // Добавленные классы

            tags.forEach(function(tag) {
                if(tag.type === 'tag-open') {
                    tree.push(tag);
                    addClasses(tag.name, tag.classes, getTagsWithClassesCount());
                    tag.single && tree.pop();
                } else {
                    tree.pop();
                }
            });

            /**
             * Получить текущее количество тегов с классами
             * @private
             * @returns {number}
             */
            function getTagsWithClassesCount() {

                var count = -1;

                tree.forEach(function(tag) {
                    tag.classes.length > 0 && count++;
                });

                return count;
            }

            /**
             * Добавить класс к выводу
             * @private
             * @param {string} tag Имя тега
             * @param {Array} tagClasses Массив классов тега
             * @param {number} level Уровень вложенности тега
             */
            function addClasses(tag, tagClasses, level) {

                tagClasses.forEach(function(cls) {

                    if(~exist.indexOf(cls)) return;
                    exist.push(cls);

                    classes.push({
                        tag: tag,
                        name: cls,
                        level: level
                    });
                });
            }

            return classes;
        }

        /**
         * Нужно ли указывать тег в селекторе
         * @private
         * @param {string} tag Имя тега
         * @returns {boolean}
         */
        function isOkTag(tag) {
            var paramsTag = that.params.tag;
            if(isBoolean(paramsTag)) return paramsTag;
            return !!~paramsTag.indexOf(tag);
        }

        /**
         * Получить открывающую скобку
         * @private
         * @param {string} indent Сформированный отступ до селектора
         * @returns {string}
         */
        function getBrace(indent) {
            switch(that.params.brace) {
                case 'default':
                    return ' {';
                case 'newline':
                    return '\n' + indent + '{';
            }
        }

        /**
         * Сформировать CSS-каркас
         * @private
         * @param {Array} classes Плоский массив классов с указанием их уровня вложенности
         * @returns {string}
         */
        function genCSSSkeleton(classes) {

            var css = [];

            classes.forEach(function(cls) {

                var paramsIndent = that.params.indent,
                    indent = !that.params.flat ? duplicateStr(paramsIndent, cls.level) : '',
                    innerIndent = that.params.inner ? '\n' + indent + paramsIndent + '\n' + indent : '',
                    tag = isOkTag(cls.tag) ? cls.tag : '';

                css.push(indent + tag + '.' + cls.name + getBrace(indent) + innerIndent + '}');
            });

            return css.join('\n' + that.params.line);
        }

        /**
         * Получить информационный массив по HTML-структуре
         * @private
         * @param {string} html Исходный HTML
         * @returns {Array}
         */
        function getHtmlStructureInfo(html) {
            return searchOpenTags(html)
                .concat(searchCloseTags(html))
                .concat(searchClasses(html))
                .sort(function(a, b) {
                    return a.position - b.position;
                });
        }

        return genCSSSkeleton(
            getClassLevels(
                putClassesIntoTags(
                    getHtmlStructureInfo(this.html)
                )
            )
        );
    }
};

global.Autoclasscss = Autoclasscss;

})(this, undefined);