'use strict';
/**
* The `kss/lib/parse` module is normally accessed via the
* [`parse()`]{@link module:kss.parse} method of the `kss` module:
* ```
* const kss = require('kss');
* let styleGuide = kss.parse(input, options);
* ```
* @private
* @module kss/lib/parse
*/
const KssStyleGuide = require('./kss_style_guide.js');
const md = require('./md.js');
const path = require('path');
/**
* Convert colors doc block to a collection of color objects
*
* @private
* @param {String} text paragraph from a comment block.
* @returns {Array} collection of color objects `{name: '…', color: '…'}`
*/
const parseColors = function(text) {
// Replace runs of white space with a single space.
text = text.trim();
const colors = [];
// it support every CSS4 colors
// match color regex: https://regex101.com/r/V2VROM/1/
// complete regex: https://regex101.com/r/43o75I/3
const regex = /^(?:(\S+)\s*:\s*)?([a-zA-Z]+|#[0-9a-f]{3}|#(?:[0-9a-f]{2}){2,4}|(?:rgb|hsl)a?\((?:-?\d+%?[,\s]+){2,3}\s*[\d\.]+%?\))(?:\s*-\s*(.*))?$/gmi;
let test = regex.exec(text);
while (test !== null) {
const color = {
color: test[2]
};
if (test[1]) {
color.name = test[1];
}
if (test[3]) {
color.description = test[3];
}
// @TODO: add converted values
// color.hex = hexToRgb(color.hex);
// color.rgb = hexToRgb(color.hex);
// color.hsl = rgbToHsl(color.rgb);
colors.push(color);
test = regex.exec(text);
}
return colors;
};
/**
* Convert String to Float
*
* @private
* @param {String} value string of a number
* @returns {Float} string converted
*/
const toFloat = function(value) {
return isNaN(value) ? 0 : parseFloat(value);
};
// @TODO: Replace {base, path, contents} with Vinyl.
/**
* Parse an array/string of documented CSS, or an array of file objects with
* their content.
*
* Each File object in the array should be formatted as:
* `{ base: "path to source directory", path: "full path to file", contents: "content" }`.
*
* @alias module:kss.parse
* @param {*} input The input to parse
* @param {Object} [options] Options to alter the output content. Same as the
* options in [`traverse()`]{@link module:kss.traverse}.
* @returns {KssStyleGuide} Returns a `KssStyleGuide` object.
*/
const parse = function(input, options) {
// Default parsing options.
options = options || {};
if (typeof options.markdown === 'undefined') {
options.markdown = true;
}
if (typeof options.header === 'undefined') {
options.header = true;
}
options.custom = options.custom || [];
// Massage our input into a "files" array of Vinyl-like objects.
let files = [];
const styleGuide = {
files: [],
sections: []
};
// If supplied a string.
if (typeof input === 'string') {
files.push({
contents: input
});
// If supplied an array of strings or objects.
} else {
files = input.map(file => {
if (typeof file === 'string') {
return {contents: file};
} else {
styleGuide.files.push(file.path);
return file;
}
});
}
for (let file of files) {
// Retrieve an array of "comment block" strings, and then evaluate each one.
let comments = findCommentBlocks(file.contents);
for (let comment of comments) {
// Create a new, temporary section object with some default values.
// "raw" is a comment block from the array above.
let newSection = {
raw: comment.raw,
header: '',
description: '',
modifiers: [],
parameters: [],
markup: '',
source: {
// Always display using UNIX separators.
filename: file.base ? path.relative(file.base, file.path).replace(/\\/g, '/') : file.path,
path: file.path ? file.path : '',
line: comment.line
}
};
// Split the comment block into paragraphs.
let paragraphs = comment.text.split('\n\n');
// Ignore this block if a style guide reference number is not listed.
for (let i = 0; i < paragraphs.length; i++) {
let reference = findReference(paragraphs[i]);
if (reference) {
newSection.reference = reference;
paragraphs.splice(i, 1);
}
}
if (!newSection.reference) {
continue;
}
// Before anything else, process the properties that are clearly labeled
// and can be found right away and then removed.
processProperty.call(newSection, paragraphs, 'Colors', parseColors);
processProperty.call(newSection, paragraphs, 'Markup');
processProperty.call(newSection, paragraphs, 'Weight', toFloat);
// Process custom properties.
for (let customProperty of options.custom) {
processProperty.call(newSection, paragraphs, customProperty);
}
// If the block is just a reference, copy the reference into the header.
if (paragraphs.length === 0) {
newSection.header = newSection.reference;
// If the block has just 1 paragraph, it is just a header and a reference.
} else if (paragraphs.length === 1) {
newSection.header = newSection.description = paragraphs[0];
// If it has 2+ paragraphs, search for modifiers.
} else {
// Extract the approximate header, description and modifiers paragraphs.
// The modifiers will be split into an array of lines.
newSection.header = paragraphs[0];
let possibleModifiers = paragraphs.pop();
newSection.modifiers = possibleModifiers.split('\n');
newSection.description = paragraphs.join('\n\n');
// Check the modifiers paragraph. Does it look like it's a list of
// modifiers, or just another paragraph of the description?
let numModifierLines = newSection.modifiers.length,
hasModifiers = true,
lastModifier = 0;
for (let j = 0; j < numModifierLines; j += 1) {
if (newSection.modifiers[j].match(/^\s*.+?\s+\-\s/g)) {
lastModifier = j;
} else if (j === 0) {
// The paragraph doesn't start with a modifier, so bail out.
hasModifiers = false;
j = numModifierLines;
} else {
// If the current line doesn't match a modifier, it must be a
// multi-line modifier description.
newSection.modifiers[lastModifier] += ' ' + newSection.modifiers[j].replace(/^\s+|\s+$/g, '');
// We will strip this blank line later.
newSection.modifiers[j] = '';
}
}
// Remove any blank lines added.
newSection.modifiers = newSection.modifiers.filter(line => { return line !== ''; });
// If it's a modifiers paragraph, turn each one into a modifiers object.
if (hasModifiers) {
// If the section has markup, create KssModifier objects.
if (newSection.markup) {
newSection.modifiers = createModifiers(newSection.modifiers, options);
} else {
// If the section has no markup, create KssParameter objects.
newSection.parameters = createParameters(newSection.modifiers, options);
newSection.modifiers = [];
}
// Otherwise, add it back to the description.
} else {
newSection.description += '\n\n' + possibleModifiers;
newSection.modifiers = [];
}
}
// Squash the header into a single line.
newSection.header = newSection.header.replace(/\n/g, ' ');
// Check the section's status.
newSection.deprecated = hasPrefix(newSection.description, 'Deprecated');
newSection.experimental = hasPrefix(newSection.description, 'Experimental');
// If a separate header is requested, remove the first paragraph from the
// description.
if (options.header) {
if (newSection.description.match(/\n{2,}/)) {
newSection.description = newSection.description.replace(/^.*?\n{2,}/, '');
} else {
newSection.description = '';
}
}
// Markdown Parsing.
if (options.markdown) {
newSection.description = md.render(newSection.description);
}
// Add the new section instance to the sections array.
styleGuide.sections.push(newSection);
}
}
return new KssStyleGuide(styleGuide);
};
/**
* Returns an array of comment blocks found within a string.
*
* @private
* @param {String} input The string to search.
* @returns {Array} An array of blocks found as objects containing line, text,
* and raw properties.
*/
const findCommentBlocks = function(input) {
/* eslint-disable key-spacing */
const commentRegex = {
single: /^\s*\/\/.*$/,
docblockStart: /^\s*\/\*\*\s*$/,
multiStart: /^\s*\/\*+\s*$/,
multiFinish: /^\s*\*\/\s*$/
};
/* eslint-enable key-spacing */
// Convert Windows/Mac line endings to Unix ones.
input = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
let blocks = [],
block = {
line: 0,
text: '',
raw: ''
},
indentAmount = false,
insideSingleBlock = false,
insideMultiBlock = false,
insideDocblock = false;
// Add an empty line to catch any comment at the end of the input.
input += '\n';
const lines = input.split('\n');
for (let i = 0; i < lines.length; i += 1) {
let line = lines[i];
// Remove trailing space.
line = line.replace(/\s*$/, '');
// Single-line parsing.
if (!insideMultiBlock && !insideDocblock && line.match(commentRegex.single)) {
block.raw += line + '\n';
// Add the current line (and a newline) minus the comment marker.
block.text += line.replace(/^\s*\/\/\s?/, '') + '\n';
if (!insideSingleBlock) {
block.line = i + 1;
}
insideSingleBlock = true;
// Continue to next line.
continue;
}
// If we have reached the end of the current block, save it.
if (insideSingleBlock || (insideMultiBlock || insideDocblock) && line.match(commentRegex.multiFinish)) {
let doneWithCurrentLine = !insideSingleBlock;
block.text = block.text.replace(/^\n+/, '').replace(/\n+$/, '');
blocks.push(block);
insideMultiBlock = insideDocblock = insideSingleBlock = indentAmount = false;
block = {
line: 0,
text: '',
raw: ''
};
// If we "found" the end of a single-line comment block, we are not done
// processing the current line and cannot skip the rest of this loop.
if (doneWithCurrentLine) {
continue;
}
}
// Docblock parsing.
if (line.match(commentRegex.docblockStart)) {
insideDocblock = true;
block.raw += line + '\n';
block.line = i + 1;
continue;
}
if (insideDocblock) {
block.raw += line + '\n';
// Add the current line (and a newline) minus the comment marker.
block.text += line.replace(/^\s*\*\s?/, '') + '\n';
continue;
}
// Multi-line parsing.
if (line.match(commentRegex.multiStart)) {
insideMultiBlock = true;
block.raw += line + '\n';
block.line = i + 1;
continue;
}
if (insideMultiBlock) {
block.raw += line + '\n';
// If this is the first interior line, determine the indentation amount.
if (indentAmount === false) {
// Skip initial blank lines.
if (line === '') {
continue;
}
indentAmount = line.match(/^\s*/)[0];
}
// Always strip same indentation amount from each line.
block.text += line.replace(new RegExp('^' + indentAmount), '', 1) + '\n';
}
}
return blocks;
};
/**
* Takes an array of modifier lines, and turns it into a JSON equivalent of
* KssModifier.
*
* @private
* @param {Array} rawModifiers Raw Modifiers, which should all be strings.
* @param {Object} options The options object.
* @returns {Array} The modifier instances created.
*/
const createModifiers = function(rawModifiers, options) {
return rawModifiers.map(entry => {
// Split modifier name and the description.
let modifier = entry.split(/\s+\-\s+/, 1)[0];
let description = entry.replace(modifier, '', 1).replace(/^\s+\-\s+/, '');
// Markdown parsing.
if (options.markdown) {
description = md.renderInline(description);
}
return {
name: modifier,
description: description
};
});
};
/**
* Takes an array of parameter lines, and turns it into instances of
* KssParameter.
*
* @private
* @param {Array} rawParameters Raw parameters, which should all be strings.
* @param {Object} options The options object.
* @returns {Array} The parameter instances created.
*/
const createParameters = function(rawParameters, options) {
return rawParameters.map(entry => {
// Split parameter name and the description.
let parameter = entry.split(/\s+\-\s+/, 1)[0];
let defaultValue = '';
let description = entry.replace(parameter, '', 1).replace(/^\s+\-\s+/, '');
// Split parameter name and the default value.
if (/\s+=\s+/.test(parameter)) {
let tokens = parameter.split(/\s+=\s+/);
parameter = tokens[0];
defaultValue = tokens[1];
}
// Markdown parsing.
if (options.markdown) {
description = md.renderInline(description);
}
return {
name: parameter,
defaultValue: defaultValue,
description: description
};
});
};
/**
* Check a section for the reference number it may or may not have.
*
* @private
* @param {Array} text An array of the paragraphs in a single block.
* @returns {Boolean|String} False if not found, otherwise returns the reference
* number as a string.
*/
const findReference = function(text) {
// Replace runs of white space with a single space.
text = text.trim().replace(/\s+/g, ' ');
// Search for the "styleguide" (or "style guide") keyword at the start of the
// paragraph.
let regex = /^style\s?guide\s?[-:]?\s?/i;
if (regex.test(text)) {
return text.replace(regex, '');
}
return false;
};
/**
* Checks if there is a specific property in the comment block, adds it to
* `this`, and removes it from the original array of paragraphs.
*
* @private
* @param {Array} paragraphs An array of the paragraphs in a single comment
* block.
* @param {String} propertyName The name of the property to search for.
* @param {Function} [processValue] A function to massage the value before it is
* inserted into `this`.
*/
const processProperty = function(paragraphs, propertyName, processValue) {
let indexToRemove = false;
propertyName = propertyName.toLowerCase();
for (let i = 0; i < paragraphs.length; i++) {
if (hasPrefix(paragraphs[i], propertyName)) {
this[propertyName] = paragraphs[i].replace(new RegExp('^\\s*' + propertyName + '\\:\\s+?', 'gmi'), '');
if (typeof processValue === 'function') {
this[propertyName] = processValue(this[propertyName]);
}
indexToRemove = i;
break;
}
}
if (indexToRemove !== false) {
paragraphs.splice(indexToRemove, 1);
}
};
/**
* Essentially this function checks if a string is prefixed by a particular
* attribute, e.g. 'Deprecated:' and 'Markup:'
*
* @private
* @param {String} description The string to check.
* @param {String} prefix The prefix to search for.
* @returns {Boolean} Whether the description contains the specified prefix.
*/
const hasPrefix = function(description, prefix) {
return (new RegExp('^\\s*' + prefix + '\\:', 'gmi')).test(description);
};
module.exports = parse;