UNPKG

ui-lab

Version:

A Pattern-Driven UI Development Lab.

776 lines (665 loc) 24.7 kB
/*! * UI Lab v0.1.11, 17 June, 2014 * By Amsul, http://amsul.ca * Hosted on http://github.com/amsul/ui-lab */ 'use strict'; var glob = require('glob') var fs = require('fs') var iconv = require('iconv-lite') require('colors') /** * Synchronously read a file. */ function readFileSync(filePath) { var buffer = fs.readFileSync(filePath) return iconv.decode(buffer, 'utf8') } /** * Get declarations from a path. */ function getPathDeclarations(globPattern) { var filePaths = glob.sync(globPattern) var declarations = [] filePaths.forEach(function(filePath) { var pathSplit = filePath.split('/') var pattern = pathSplit[pathSplit.length - 2].replace(/^\[\d+\]/, '') var variation = pathSplit[pathSplit.length - 1].replace(/^\[\d+\]/, '').replace(/\.\w+?$/, '') var declaration = { filePath: filePath, pattern: pattern, variation: variation, code: readFileSync(filePath) } declarations.push(declaration) }) return declarations } /** * Get declarations from a file. */ function getFileDeclarations(filePath, options) { var fileContents = readFileSync(filePath) var declarationRegExp = /([\ \t]*\/\*[\s\S]*?\*\/)\n*([\s\S]*)/ var declarations = [] var match options = options || {} while /*if*/ ( (match = fileContents.match(declarationRegExp)) ) { // NOTE: assignment var declaration = parseDeclarationFromMatch(match, filePath); fileContents = fileContents.slice(declaration.length) verifyDeclarationContext(declaration, declarations[declarations.length-1], filePath, options) if ( verifyDeclarationValidity(declaration, options) ) { declarations.push(declaration) } } // Make sure a declaration was found. if ( !declarations.length && !options.allowEmpty ) { warn('No declarations found in "' + filePath + '".') } // If there’s a trailing chunk left over, add that to the actual code. if ( declarations.length ) { var lastDeclaration = declarations[declarations.length - 1] if ( lastDeclaration.codeAfter ) { lastDeclaration.code += lastDeclaration.codeAfter lastDeclaration.codeAfter = '' } } return declarations } /** * Parse a declaration object from a content match. */ function parseDeclarationFromMatch(match, filePath) { // Match the base of the declaration. var declarationComment = match[1] var declarationContent = match[2] // Match the component of the declaration. var componentMatch = declarationComment.match(/<([\w-_]+)>\s*?([\w-_]+)(?:\s*([\s\S]*?)(?=```|\*\/))?/) var componentType = componentMatch && componentMatch[1] || '' var componentName = componentMatch && componentMatch[2] || '' var componentDescription = componentMatch && componentMatch[3] || '' // Match the demo of the declaration. var demoMatch = declarationComment.match(/```(\w+)?([\s\S]*?)```/) var demoLanguage = demoMatch && demoMatch[1] var demoContent = demoMatch && demoMatch[2] // Match the code of the declaration. var declarationCode = '' var declarationCodeAfter = '' var codeMatch var codeFirstChunk var codeFirstComment var codeTrailingChunk // var i = 0 while ( // i < 30 && ( codeMatch = declarationContent.match(/([\s\S]*?)(\/\*[\s\S]*?\*\/)([\s\S]*)/) ) ) { // i += 1 var parsedLength = 0 codeFirstChunk = codeMatch[1] codeFirstComment = codeMatch[2] codeTrailingChunk = codeMatch[3] // Update the declaration code with the first chunk. declarationCode += codeFirstChunk parsedLength += codeFirstChunk.length // If the first comment has a declaration, // add that to the code after. if ( codeFirstComment.match(/<[\w-_]*>/) ) { declarationCodeAfter = codeFirstComment + codeTrailingChunk parsedLength += declarationCodeAfter.length } // Otherwise add the first comment to the actual code. else { declarationCode += codeFirstComment parsedLength += codeFirstComment.length } // Reduce the length of the declaration’s content. declarationContent = declarationContent.slice(parsedLength) } // If there’s a trailing chunk left over, add that to the code after. if ( codeTrailingChunk && !declarationCodeAfter.match( new RegExp(codeTrailingChunk.replace(/[\[\]\(\)\{\}\|\/\\\.\+\*\?\^\$]/g, '\\$&') + '$') ) ) { declarationCodeAfter += codeTrailingChunk } // If there’s a description, clean up the white spacing. if ( componentDescription ) { componentDescription = componentDescription.replace(/\n[\ \t]*(?![\ \t]*\n)/g, ' ') } // If there’s no declaration code, fallback to the content. if ( !declarationCode ) { declarationCode = declarationContent } // Create and return the declaration object. var declaration = { filePath: filePath, componentType: componentType, componentName: componentName, componentDescription: cleanWrappingWhitespace(componentDescription), demoLanguage: demoLanguage, demoContent: cleanWrappingWhitespace(demoContent, { stripIndent: true }), code: cleanWrappingWhitespace(declarationCode, { indent: true }), codeAfter: declarationCodeAfter, length: declarationComment.length + declarationCode.length } return declaration } /** * Parse JSON-like data that takes comments as description. */ function parseJSONLikeData(content, filePath) { if ( !content ) { return content } content = content.replace(/^\{/, '') var match var properties = [] var regex = /^(((?:\s*\/\/\s*[^\n]+)*)\s*([\w]+)\s*:\s*([^\n]+)(?:,|\s*\}))/ while ( (match = content.match(regex)) ) { var chunk = match[1] var description = match[2] var name = match[3] var value = match[4] var property = { description: cleanSingleLineComments(description), name: name, value: value } console.log(property); if ( !property.name || !property.value ) { warn('Invalid property found in the file "' + filePath + '".') } properties.push(property) content = content.replace(chunk, '') } return properties; } /** * Verify if a declaration is within the right context. */ function verifyDeclarationContext(declaration, prevDeclaration, filePath, options) { var currentType = declaration.componentType var previousType = prevDeclaration && prevDeclaration.componentType var context = options && options.context && options.context[currentType] if ( !previousType && context && context.length ) { warn('The "<' + currentType + '>" declaration has no context ' + 'in the file "' + filePath + '" - but it must be declared ' + 'after one of the following: "<' + context.join('>", "<') + '>".') } if ( !previousType && 'first' in options && options.first !== currentType ) { warn('The "<' + options.first + '>" declaration must be ' + 'the first declaration in the file "' + filePath + '"' + (currentType ? ' - but "<' + currentType + '>" appears first instead' : '' ) + '.') } if ( previousType && context && context.indexOf(previousType) < 0 ) { warn('The "<' + currentType + '>" declaration appears after ' + '"<' + previousType + '>" in the file "' + filePath + '" - ' + 'but it must be declared after one of the following: ' + '"<' + context.join('>", "<') + '>".') } if ( previousType && 'first' in options && options.first === currentType && options.onlyOnce ) { warn('The "<' + currentType + '>" declaration can only appear once ' + 'in the file "' + filePath + '".') } } /** * Verify if a declaration is valid. */ function verifyDeclarationValidity(declaration, options) { var isOkay = true var checks = [ function isComponentDeclaration() { return declaration.componentType && declaration.componentName }, function isComponentAllowed() { var type = declaration.componentType var variation = declaration.componentName var isAllowed = options.allow && options.allow.indexOf(type) > -1 if ( !isAllowed ) { warn('Unrecognized declaration ' + '"<' + type + '> ' + variation + '" found in "' + declaration.filePath + '". ' + ( options.allow ? 'The declaration type must be one of the following: ' + '"<' + options.allow.join('>", "<') + '>".' : 'There are no known allowed declaration types provided.' ) ) } return isAllowed } ] for ( var i = 0; i < checks.length; i += 1 ) { isOkay = !!checks[i]() if ( !isOkay ) { break } } return isOkay } /** * Build patterns found within the markups’ glob pattern. */ function buildPatterns(options) { var patternsRegistry = { variables: [], helpers: [], objects: [] } if ( options.variables ) { buildPatternsForVariables(patternsRegistry.variables, options) } if ( options.helpers ) { buildPatternsForHelpers(patternsRegistry.helpers, options) } if ( options.objects ) { buildPatternsForObjects(patternsRegistry.objects, options) } return patternsRegistry } /** * Build patterns for variables using the source and demos. */ function buildPatternsForVariables(variablesPatternsRegistry, options) { var cachedNames = {} var cachedPatterns = {} logSilent('Generating the variables’ styles and markups... ') var variableStylesPaths = glob.sync(options.variables) var latestPatternName variableStylesPaths.forEach(function(variableStylesPath) { var declarations = getFileDeclarations(variableStylesPath, { allow: ['variables', 'group'], first: 'variables', context: { 'group': ['variables', 'group'] } }) var name var demo declarations.forEach(function(declaration, index) { // if(index>0)return var variationName = declaration.componentName var description = declaration.componentDescription if ( declaration.componentType == 'variables' ) { name = variationName variationName = 'base' description = 'The base variables' demo = declaration.demoContent } var variablesPattern if ( name in cachedPatterns ) { variablesPattern = cachedPatterns[name] } else { variablesPattern = cachedPatterns[name] = { name: name, title: capitalizeSplit(name), description: declaration.componentDescription, variations: [] } } var fullName = name + '/' + variationName if ( fullName in cachedNames ) { warn('Styles for the pattern "' + fullName + '" already exist.') } cachedNames[fullName] = true if ( !declaration.code ) return var variablesPatternVariation = { name: variationName, title: capitalizeSplit(variationName), description: description, demos: interpolateVariables(declaration.code, demo), source: { styles: { path: declaration.filePath, code: declaration.code, }, markup: { path: declaration.filePath, code: demo } } } variablesPattern.variations.push(variablesPatternVariation) if ( latestPatternName !== name ) { latestPatternName = name variablesPatternsRegistry.push(cachedPatterns[name]) } }) }) logSilent('OK\n') } /** * Build patterns for helpers using styles and markups. */ function buildPatternsForHelpers(helpersPatternsRegistry, options) { var cachedNames = {} var cachedVariations = {} var cachedPatterns = {} var cachedCompletedVariations = {} logSilent('Generating the helpers’ styles... ') var helpersStylesPaths = glob.sync(options.helpers.styles) helpersStylesPaths.forEach(function(helpersStylesPath) { var declarations = getFileDeclarations(helpersStylesPath, { allow: ['helpers', 'group'], first: 'helpers', onlyOnce: true, context: { 'group': ['helpers', 'group'] } }) var pathDeclaration = getPathDeclarations(helpersStylesPath) var name = pathDeclaration[0].variation declarations.forEach(function(declaration, index) { // if(index>0)return var variationName = declaration.componentName var description = declaration.componentDescription if ( declaration.componentType == 'helpers' ) { name = variationName variationName = 'base' description = 'The base helpers' } var helperPattern if ( name in cachedPatterns ) { helperPattern = cachedPatterns[name] } else { helperPattern = cachedPatterns[name] = { name: name, title: capitalizeSplit(name), description: declaration.componentDescription, variations: [] } } var fullName = name + '/' + variationName if ( fullName in cachedNames ) { warn('Styles for the pattern "' + fullName + '" already exist.') } cachedNames[fullName] = true if ( !declaration.code ) return var helperPatternVariation = { name: variationName, title: capitalizeSplit(variationName), description: description, source: { styles: { path: declaration.filePath, component: declaration.componentType, code: declaration.code } } } cachedVariations[fullName] = helperPatternVariation }) }) logSilent('OK\n') logSilent('Generating the helpers’ markups... ') var helpersDeclarations = getPathDeclarations(options.helpers.markups) var latestHelperName helpersDeclarations.forEach(function(helpersDeclaration, index) { // if(index)return var name = helpersDeclaration.pattern var variationName = helpersDeclaration.variation var fullName = name + '/' + variationName if ( !(fullName in cachedVariations) ) { warn('Styles for the pattern "' + fullName + '" are not defined.') } cachedCompletedVariations[fullName] = true var patternVariation = cachedVariations[fullName] patternVariation.demo = helpersDeclaration.code patternVariation.source.markup = { path: helpersDeclaration.filePath, code: helpersDeclaration.code } cachedPatterns[name].variations.push(patternVariation) if ( latestHelperName !== name ) { latestHelperName = name helpersPatternsRegistry.push(cachedPatterns[name]) } }) logSilent('OK\n') // Verify that all styles have been paired with markups. logSilent('Verifying helpers are all matched... ') for ( var fullName in cachedVariations ) { if ( !(fullName in cachedCompletedVariations) ) { warn('Styles for the pattern "' + fullName + '" have no markup.') } } logSilent('OK\n') } /** * Build patterns for objects using styles, markups, and apis. */ function buildPatternsForObjects(objectsPatternsRegistry, options) { var cachedNames = {} var cachedVariations = {} var cachedCompletedVariations = {} var cachedPatterns = {} logSilent('Generating the objects’ styles... ') var objectsStylesPaths = glob.sync(options.objects.styles) objectsStylesPaths.forEach(function(objectsStylesPath,index) { // if(index>0)return var declarations = getFileDeclarations(objectsStylesPath, { allow: ['block', 'element', 'modifier', 'element-modifier'], first: 'block', onlyOnce: true, context: { 'element': ['block', 'element', 'modifier'], 'modifier': ['block', 'element', 'modifier', 'element-modifier'], 'element-modifier': ['block', 'element', 'modifier', 'element-modifier'] } }) var name var namespace declarations.forEach(function(declaration, index) { // if(index>0)return var variationName = declaration.componentName var description = declaration.componentDescription if ( declaration.componentType == 'block' ) { if ( !index ) { name = variationName description = 'The base object' } namespace = variationName = 'base' } else if ( declaration.componentType == 'modifier' ) { namespace = variationName } else { variationName = namespace + '-' + variationName } var objectPattern if ( name in cachedPatterns ) { objectPattern = cachedPatterns[name] } else { objectPattern = cachedPatterns[name] = { name: name, title: capitalizeSplit(name), description: declaration.componentDescription, variations: [], api: {} } } var fullName = name + '/' + variationName if ( variationName in cachedVariations ) { warn('Styles for the pattern "' + fullName + '" already exist.') } cachedNames[name] = true var objectPatternVariation = { name: variationName, title: capitalizeSplit(variationName), description: description, source: { styles: { path: declaration.filePath, component: declaration.componentType, code: declaration.code } } } cachedVariations[fullName] = objectPatternVariation }) }) logSilent('OK\n') logSilent('Generating the objects’ apis... ') var objectsScriptsPaths = glob.sync(options.objects.apis) objectsScriptsPaths.forEach(function(objectsScriptsPath) { var declarations = getFileDeclarations(objectsScriptsPath, { allow: ['api'], allowEmpty: true }) declarations.forEach(function(declaration) { var name = declaration.componentName if ( !(name in cachedNames) ) { warn('Styles for the pattern "' + name + '" are not defined.') } if ( !declaration.demoContent ) { warn('API demos for the pattern "' + name + '" are not defined.') } cachedNames[name] = 'done' var patternRegistry = cachedPatterns[name] patternRegistry.api = { path: declaration.filePath, description: declaration.componentDescription, properties: parseJSONLikeData(declaration.demoContent, declaration.filePath) } }) }) logSilent('OK\n') logSilent('Generating the objects’ markups... ') var objectsDeclarations = getPathDeclarations(options.objects.markups) var latestObjectName objectsDeclarations.forEach(function(objectsDeclaration, index) { // if(index)return var name = objectsDeclaration.pattern var variationName = objectsDeclaration.variation var fullName = name + '/' + variationName if ( !(fullName in cachedVariations) ) { warn('Styles for the pattern "' + fullName + '" are not defined.') } cachedCompletedVariations[fullName] = true var patternVariation = cachedVariations[fullName] patternVariation.demo = objectsDeclaration.code patternVariation.source.markup = { path: objectsDeclaration.filePath, code: objectsDeclaration.code } cachedPatterns[name].variations.push(patternVariation) if ( latestObjectName !== name ) { latestObjectName = name objectsPatternsRegistry.push(cachedPatterns[name]) } }) logSilent('OK\n') // Verify that all styles have been paired with markups. logSilent('Verifying objects are all matched... ') for ( var fullName in cachedVariations ) { if ( !(fullName in cachedCompletedVariations) ) { warn('Styles for the pattern "' + fullName + '" have no markup.') } } logSilent('OK\n') } /** * Helper functions to title case a string. */ function capitalize(word) { return word ? word[0].toUpperCase() + word.slice(1) : '' } function capitalizeSplit(string) { return (string || '').split(''). map(function(letter) { return letter.match(/[A-Z]/) ? ' ' + letter : letter }). join('').split(/-|_/). map(capitalize).join(' ') } /** * Helper function to manipulate whitespace in a string. */ function cleanWrappingWhitespace(string, options) { var match if ( !string ) { return string } if ( options ) { if ( options.indent ) { match = string.match(/([\ \t]+)?([\s\S]+?)([\n\ \t]*?$)/) return match && (match[1] || '') + (match[2] || '') } if ( options.stripIndent ) { var firstIndent = string.match(/^\n*([ \t]+)/) firstIndent = firstIndent && firstIndent[1] string = cleanWrappingWhitespace(string) string = string.replace(new RegExp('\n' + firstIndent, 'g'), '\n') return string } } match = string.match(/([\n\ \t]+)?([\s\S]+?)([\n\ \t]*?$)/) return match && match[2] || '' } /** * Helper function to clean single line comments along with whitespace. */ function cleanSingleLineComments(string) { string = string.replace(/[ \t]*\/\/[ \t]*/g, '') return cleanWrappingWhitespace(string) } /** * Helper function to interpolate variables into a demo. */ function interpolateVariables(code, demo) { if ( !demo ) { return [] } var regex = /(@[\w-_]+)\s*?:(?:\s+)?([\s\S]*?);/g, matches = code.match(regex) if ( matches ) { matches = matches.map(function(statement) { var split = statement.split(regex) return demo.replace(/\{\$1\}/g, split[1]).replace(/\{\$2\}/g, split[2]) }) } return matches } /** * Environment variables. */ var MODE = 'debug' /** * Export the public API. */ module.exports = { getFileDeclarations: getFileDeclarations, getPathDeclarations: getPathDeclarations, buildPatterns: buildPatterns, setMode: function(mode) { MODE = mode } } /** * Log warnings to the console based on the mode. */ function warn(msg) { if ( MODE == 'debug' || MODE == 'test' ) { throw new ReferenceError(msg) } else { log(msg.yellow) } } function log(msg) { process.stdout.write(msg) } function logSilent(msg) { if ( MODE == 'debug' || MODE == 'test' ) { log(msg.match(/^OK(\n|$)/) ? msg.green : msg.cyan) } }