diff --git a/.gitignore b/.gitignore index 74ab03195..a1c22e42c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +npm-debug.log .DS_Store latest-change.txt patternlab.json diff --git a/core/lib/parameter_hunter.js b/core/lib/parameter_hunter.js index 9567f2a46..f64886c31 100644 --- a/core/lib/parameter_hunter.js +++ b/core/lib/parameter_hunter.js @@ -1,10 +1,10 @@ -/* - * patternlab-node - v1.2.0 - 2016 - * +/* + * patternlab-node - v1.2.0 - 2016 + * * Brian Muenzenmeyer, and the web community. - * Licensed under the MIT license. - * - * Many thanks to Brad Frost and Dave Olsen for inspiration, encouragement, and advice. + * Licensed under the MIT license. + * + * Many thanks to Brad Frost and Dave Olsen for inspiration, encouragement, and advice. * */ @@ -18,109 +18,325 @@ var parameter_hunter = function () { style_modifier_hunter = new smh(), pattern_assembler = new pa(); - function paramToJson(pString) { - var paramStringWellFormed = ''; - var paramStringTmp; - var colonPos; - var delimitPos; - var quotePos; - var paramString = pString; - - do { + // Matches next non-whitespace char + var nextNonWhiteSpaceCharRegex = /\S/; + + // Matches everything up to and including the next bare (i.e. non-quoted) + // key or value. Since this may include preceding whitespace, the key/val + // itself is captured as the 1st group. + var bareKeyOrValueRegex = /^\s*([^:,'"\s]+)/; + + // Matches everything up to and including the next single or double-quoted + // key or value. Since this may include preceding whitespace, the key/val + // itself (including quotes) is captured as the 1st group. + var quotedKeyOrValueRegex = /^\s*((['"])(\\.|[^\2\\])*?\2)/; + + /** + * Extracts the next bit of text that could be a key or value. + * + * @param text A string of input text. + * + * @param debug Set to true to enable debug log messages. + * + * @returns {*} Boolean false if the input text was empty, + * contained only whitespace or malformed + * quoted text. + * Otherwise, an object with the following + * attributes: + * "nextNspChar" : The first non-whitespace + * character that was found. + * "keyOrValue" : A complete single- or + * double-quoted string of text or + * unquoted sequence of non-whitespace + * characters. (Basically, something + * that looks like a key or value) + * "remainder" : The rest of the input string + * that follows the key, value or non-whitespace + * character that was matched. + */ + function extractNextKeyOrValue(text, debug) { + var results = {}; + + // Look for next non-whitespace character + var nextNspChar = text.match(nextNonWhiteSpaceCharRegex); + if (nextNspChar === null) { + // String is either empty or entirely whitespace + return false; + } - //if param key is wrapped in single quotes, replace with double quotes. - paramString = paramString.replace(/(^\s*[\{|\,]\s*)'([^']+)'(\s*\:)/, '$1"$2"$3'); + results.nextNspChar = nextNspChar[0]; + var matches; - //if params key is not wrapped in any quotes, wrap in double quotes. - paramString = paramString.replace(/(^\s*[\{|\,]\s*)([^\s"'\:]+)(\s*\:)/, '$1"$2"$3'); + if (results.nextNspChar === '"' || results.nextNspChar === "'") { + // Double- or single-quote should indicate start + // of quoted key or value + matches = text.match(quotedKeyOrValueRegex); - //move param key to paramStringWellFormed var. - colonPos = paramString.indexOf(':'); + if (matches === null) { + // Means quoted string was never closed + if (debug) { + console.warn("Parameter hunter encountered incomplete quoted string in: ", text); + } + return false; + } + else { + results.keyOrValue = matches[1]; + results.remainder = text.substr(matches[0].length); + return results; + } + } + else { + // Bare key or value + matches = text.match(bareKeyOrValueRegex); + + if (matches === null) { + // Means there was no bare key or value (could happen if the next + // non-whitespace char was : or , ) + + // Still return a results object as extractNextKeyValPair() uses this + // function to find : and , too. + results.keyOrValue = false; + results.remainder = text.substr(text.indexOf(results.nextNspChar) + 1); // Everything after the nextNspChar we found + return results; + } + else { + results.keyOrValue = matches[1]; + results.remainder = text.substr(matches[0].length); + return results; + } + } + } - //except to prevent infinite loops. - if (colonPos === -1) { - colonPos = paramString.length - 1; + /** + * Takes an object containing some key or value text and converts + * it to double-quoted text. + * + * @param extractedKeyOrVal An object, like the ones returned by + * extractNextKeyOrValue(), that has the + * following attributes: + * "keyOrValue": Text that needs to be + * double-quoted. It may be bare, + * single-quoted or double-quoted. + * "nextNspChar": The first character of + * "keyOrValue". (Which, in the case of + * quoted text, will either be a single- + * or double-quote.) + * + * @param isVal If true, bare (i.e. unquoted) input texts + * are returned as is. Useful for value text, + * where numbers or booleans should not be + * quoted. + * + * @returns {string} A string containing the "keyOrValue" text correctly + * double-quoted (unless isVal was true, in which case + * unquoted input strings remain unquoted). + */ + function doubleQuoteKeyOrValIfNeeded(extractedKeyOrVal, isVal) { + if (extractedKeyOrVal.nextNspChar === '"') { + // Already double-quoted + return extractedKeyOrVal.keyOrValue; + } + else if (extractedKeyOrVal.nextNspChar === "'") { + // Single quoted. Need to convert to double quotes + var keyOrValText = extractedKeyOrVal.keyOrValue.substr(1, (extractedKeyOrVal.keyOrValue.length - 2)); // strip off single quotes + keyOrValText = keyOrValText.replace(/\\'/g, "'"); // Un-escape, escaped single quotes + return '"' + keyOrValText.replace(/"/g, '\\"') + '"'; // Escape double quotes + } + else { + // Bare key or val + if (!isVal) { + // Keys must always be double-quoted + return '"' + extractedKeyOrVal.keyOrValue.replace(/"/g, '\\"') + '"'; // Escape double quotes } else { - colonPos += 1; + // Return as is + return extractedKeyOrVal.keyOrValue; } - paramStringWellFormed += paramString.substring(0, colonPos); - paramString = paramString.substring(colonPos, paramString.length).trim(); + } + } - //if param value is wrapped in single quotes, replace with double quotes. - if (paramString[0] === '\'') { - quotePos = paramString.search(/[^\\]'/); - //except for unclosed quotes to prevent infinite loops. - if (quotePos === -1) { - quotePos = paramString.length - 1; + // Used by extractNextKeyValPair() + var STATE_EXPECTING_KEY = 0; + var STATE_EXPECTING_COLON = 1; + var STATE_EXPECTING_VALUE = 2; + var STATE_EXPECTING_COMMA = 3; + + /** + * Extracts the next key:value pair from the input text + * and returns it in a form suitable for inclusion in a + * JSON string. + * + * @param text A string of input text that contains + * comma-separated key:value pairs, where the + * keys and values may be unquoted, single-quoted + * or double-quoted text. + * + * @param debug Set to true to enable debug log messages. + * + * @returns {*} Boolean false if the input text was empty or + * contained only whitespace. + * Otherwise an object with the following + * attributes: + * "error": A boolean indicating whether the + * parsed key:value pair was malformed (true) + * or not (false). + * "key": A string containing the double-quoted + * key text. Or boolean false, if no key + * was found. + * "val": A string containing the value text + * (double-quoted, if the input was quoted, or + * bare, if the input was unquoted). + * Or boolean false, if no value was found. + * "remainder": The rest of the input string that + * follows the key:val pair that was extracted. + */ + function extractNextKeyValPair(text, debug) { + var result = { + error: false, + key: false, + val: false, + remainder: text + }; + + var state = STATE_EXPECTING_KEY; + var extractedItem; + + while ((extractedItem = extractNextKeyOrValue(result.remainder, debug)) !== false) { + // Update remaining text in readiness for next loop iteration + result.remainder = extractedItem.remainder; + + if (!result.error) { + if (state === STATE_EXPECTING_KEY) { + if (extractedItem.keyOrValue === false) { + // Something other than key or val found + if (debug) { + console.warn("Parameter hunter expected a key but found: ", extractedItem.nextNspChar); + } + result.error = true; + } + else { + // Found key + result.key = doubleQuoteKeyOrValIfNeeded(extractedItem); + state = STATE_EXPECTING_COLON; + } } - else { - quotePos += 2; + else if (state === STATE_EXPECTING_COLON) { + // We expect keyOrValue to be false and nextNspChar to be : + if (extractedItem.keyOrValue !== false || extractedItem.nextNspChar !== ':') { + if (debug) { + console.warn("Parameter hunter expected a colon found: ", extractedItem.nextNspChar); + } + result.error = true; + } + else { + state = STATE_EXPECTING_VALUE; + } } + else if (state === STATE_EXPECTING_VALUE) { + if (extractedItem.keyOrValue === false) { + // Something other than key or val found + if (debug) { + console.warn("Parameter hunter expected a value but found: ", extractedItem.nextNspChar); + } + result.error = true; + } + else { + // Found value + result.val = doubleQuoteKeyOrValIfNeeded(extractedItem, true); + state = STATE_EXPECTING_COMMA; + } + } + else { // STATE_EXPECTING_COMMA + // We expect keyOrValue to be false and nextNspChar to be , + if (extractedItem.keyOrValue !== false || extractedItem.nextNspChar !== ',') { + if (debug) { + console.warn("Parameter hunter expected a comma found: ", extractedItem.nextNspChar); + } + result.error = true; + } + else { + // We're done parsing this key:val pair + break; + } + } + } - //prepare param value for move to paramStringWellFormed var. - paramStringTmp = paramString.substring(0, quotePos); - - //unescape any escaped single quotes. - paramStringTmp = paramStringTmp.replace(/\\'/g, '\''); - - //escape any double quotes. - paramStringTmp = paramStringTmp.replace(/"/g, '\\"'); - - //replace the delimiting single quotes with double quotes. - paramStringTmp = paramStringTmp.replace(/^'/, '"'); - paramStringTmp = paramStringTmp.replace(/'$/, '"'); - - //move param key to paramStringWellFormed var. - paramStringWellFormed += paramStringTmp; - paramString = paramString.substring(quotePos, paramString.length).trim(); + if (result.error) { + // We encountered an error. Check if we found a comma + // (which would denote the end of this broken key:val pair and + // the beginning of another) + if (extractedItem.keyOrValue === false && extractedItem.nextNspChar === ',') { + // Found comma, stop looping + break; + } // else: Keep looping through remaining text until next comma is found... } + } - //if param value is wrapped in double quotes, just move to paramStringWellFormed var. - else if (paramString[0] === '"') { - quotePos = paramString.search(/[^\\]"/); + if (result.key === false && result.val === false && !result.error) { + // Means that the input text was empty + return false; + } + else { + return result; + } + } - //except for unclosed quotes to prevent infinite loops. - if (quotePos === -1) { - quotePos = paramString.length - 1; - } - else { - quotePos += 2; - } - //move param key to paramStringWellFormed var. - paramStringWellFormed += paramString.substring(0, quotePos); - paramString = paramString.substring(quotePos, paramString.length).trim(); + /** + * Parses the patterns parameters and returns them as a + * JSON string. + * + * Note that the input should only include the text within + * the parenthesis. E.g. if the pattern was: + * 'pattern-name( param1: true, param2: "string", param3: 42 )', then only + * ' param1: true, param2: "string", param3: 42 ' should be passed to this + * function. (Leading & trailing whitespace is OK) + * + * In the above example, the output will be something like: + * '{ "param1": true, "param2": "string", "param3": 42 }'. Be aware that + * no type-checking is performed. If the input contains an unquoted value + * that is not a valid JSON boolean or number, then that will be carried + * over to the output and attempting to parse it as JSON will fail. + * + * If any key:val pairs are malformed (e.g. key or value is blank) + * they are skipped, but subsequent pairs will still be parsed. + * If the debug flag is set in the config, the malformed pairs + * will be logged as warnings. + * + * @param pString String containing the pattern's parameters. + * @param debug Set to true to enable debug log messages. + * @returns {string} The parameters as a JSON string (including + * curly braces). + */ + function paramToJson(pString, debug) { + var wellFormedKeyVals = ''; + var remainder = pString; + var lastKeyVal; + while ((lastKeyVal = extractNextKeyValPair(remainder, debug)) !== false) { + if (lastKeyVal.error) { + if (debug) { + console.warn( + "Parameter hunter skipped broken key:val pair: ", + remainder.substr(0, (remainder.length - lastKeyVal.remainder.length)) + ); + } // else: Silently ignore error } - - //if param value is not wrapped in quotes, move everthing up to the delimiting comma to paramStringWellFormed var. else { - delimitPos = paramString.indexOf(','); - - //except to prevent infinite loops. - if (delimitPos === -1) { - delimitPos = paramString.length - 1; - } - else { - delimitPos += 1; + // Add parsed key:val pair to output + if (wellFormedKeyVals !== '') { + wellFormedKeyVals += ","; } - paramStringWellFormed += paramString.substring(0, delimitPos); - paramString = paramString.substring(delimitPos, paramString.length).trim(); + wellFormedKeyVals += lastKeyVal.key + ':' + lastKeyVal.val; } - //break at the end. - if (paramString.length === 1) { - paramStringWellFormed += paramString.trim(); - paramString = ''; - break; - } - - } while (paramString); + remainder = lastKeyVal.remainder; + } - return paramStringWellFormed; + return "{" + wellFormedKeyVals + "}"; } + function findparameters(pattern, patternlab) { if (pattern.parameteredPartials && pattern.parameteredPartials.length > 0) { @@ -140,9 +356,9 @@ var parameter_hunter = function () { //strip out the additional data, convert string to JSON. var leftParen = pMatch.indexOf('('); - var rightParen = pMatch.indexOf(')'); - var paramString = '{' + pMatch.substring(leftParen + 1, rightParen) + '}'; - var paramStringWellFormed = paramToJson(paramString); + var rightParen = pMatch.lastIndexOf(')'); + var paramString = pMatch.substring(leftParen + 1, rightParen); + var paramStringWellFormed = paramToJson(paramString, patternlab.config.debug); var paramData = {}; var globalData = {}; diff --git a/test/parameter_hunter_tests.js b/test/parameter_hunter_tests.js index 96d345792..d4d563289 100644 --- a/test/parameter_hunter_tests.js +++ b/test/parameter_hunter_tests.js @@ -230,8 +230,83 @@ parameter_hunter.find_parameters(currentPattern, patternlab); test.equals(currentPattern.extendedTemplate, '
true not}"true"
'); + test.done(); + }, + + 'parameter hunter parses parameters with values containing a closing parenthesis' : function(test){ + // From issue #291 https://github.com/pattern-lab/patternlab-node/issues/291 + var currentPattern = currentPatternClosure(); + var patternlab = patternlabClosure(); + var parameter_hunter = new ph(); + + currentPattern.template = "{{> molecules-single-comment(description: 'Hello ) World') }}"; + currentPattern.extendedTemplate = currentPattern.template; + currentPattern.parameteredPartials[0] = currentPattern.template; + + parameter_hunter.find_parameters(currentPattern, patternlab); + test.equals(currentPattern.extendedTemplate, 'Hello ) World
'); + + test.done(); + }, + + 'parameter hunter parses parameters that follow a non-quoted value' : function(test){ + // From issue #291 https://github.com/pattern-lab/patternlab-node/issues/291 + var currentPattern = currentPatternClosure(); + var patternlab = patternlabClosure(); + var parameter_hunter = new ph(); + + patternlab.patterns[0].template = "{{foo}}
{{bar}}
"; + patternlab.patterns[0].extendedTemplate = patternlab.patterns[0].template; + + currentPattern.template = "{{> molecules-single-comment(foo: true, bar: \"Hello World\") }}"; + currentPattern.extendedTemplate = currentPattern.template; + currentPattern.parameteredPartials[0] = currentPattern.template; + + parameter_hunter.find_parameters(currentPattern, patternlab); + test.equals(currentPattern.extendedTemplate, 'true
Hello World
'); + + test.done(); + }, + + 'parameter hunter parses parameters whose keys contain escaped quotes' : function(test){ + // From issue #291 https://github.com/pattern-lab/patternlab-node/issues/291 + var currentPattern = currentPatternClosure(); + var patternlab = patternlabClosure(); + var parameter_hunter = new ph(); + + patternlab.patterns[0].template = "{{ silly'key }}
{{bar}}
{{ another\"silly-key }}
"; + patternlab.patterns[0].extendedTemplate = patternlab.patterns[0].template; + + currentPattern.template = "{{> molecules-single-comment('silly\\\'key': true, bar: \"Hello World\", \"another\\\"silly-key\": 42 ) }}"; + currentPattern.extendedTemplate = currentPattern.template; + currentPattern.parameteredPartials[0] = currentPattern.template; + + parameter_hunter.find_parameters(currentPattern, patternlab); + test.equals(currentPattern.extendedTemplate, 'true
Hello World
42
'); + + test.done(); + }, + + 'parameter hunter skips malformed parameters' : function(test){ + // From issue #291 https://github.com/pattern-lab/patternlab-node/issues/291 + var currentPattern = currentPatternClosure(); + var patternlab = patternlabClosure(); + var parameter_hunter = new ph(); + + patternlab.patterns[0].template = "{{foo}}
"; + patternlab.patterns[0].extendedTemplate = patternlab.patterns[0].template; + + currentPattern.template = "{{> molecules-single-comment( missing-val: , : missing-key, : , , foo: \"Hello World\") }}"; + currentPattern.extendedTemplate = currentPattern.template; + currentPattern.parameteredPartials[0] = currentPattern.template; + + parameter_hunter.find_parameters(currentPattern, patternlab); + test.equals(currentPattern.extendedTemplate, 'Hello World
'); + test.done(); } + + }; }());