| 1 | <?php |
| 2 | /* |
| 3 | +--------------------------------------------------------------------+ |
| 4 | | Copyright CiviCRM LLC. All rights reserved. | |
| 5 | | | |
| 6 | | This work is published under the GNU AGPLv3 license with some | |
| 7 | | permitted exceptions and without any warranty. For full license | |
| 8 | | and copyright information, see https://civicrm.org/licensing | |
| 9 | +--------------------------------------------------------------------+ |
| 10 | */ |
| 11 | |
| 12 | /** |
| 13 | * Parse Javascript content and extract translatable strings. |
| 14 | * |
| 15 | * @package CRM |
| 16 | * @copyright CiviCRM LLC https://civicrm.org/licensing |
| 17 | */ |
| 18 | class CRM_Utils_JS { |
| 19 | |
| 20 | /** |
| 21 | * Parse a javascript file for translatable strings. |
| 22 | * |
| 23 | * @param string $jsCode |
| 24 | * Raw Javascript code. |
| 25 | * @return array |
| 26 | * Array of translatable strings |
| 27 | */ |
| 28 | public static function parseStrings($jsCode) { |
| 29 | $strings = []; |
| 30 | // Match all calls to ts() in an array. |
| 31 | // Note: \s also matches newlines with the 's' modifier. |
| 32 | preg_match_all('~ |
| 33 | [^\w]ts\s* # match "ts" with whitespace |
| 34 | \(\s* # match "(" argument list start |
| 35 | ((?:(?:\'(?:\\\\\'|[^\'])*\'|"(?:\\\\"|[^"])*")(?:\s*\+\s*)?)+)\s* |
| 36 | [,\)] # match ")" or "," to finish |
| 37 | ~sx', $jsCode, $matches); |
| 38 | foreach ($matches[1] as $text) { |
| 39 | $quote = $text[0]; |
| 40 | // Remove newlines |
| 41 | $text = str_replace("\\\n", '', $text); |
| 42 | // Unescape escaped quotes |
| 43 | $text = str_replace('\\' . $quote, $quote, $text); |
| 44 | // Remove end quotes |
| 45 | $text = substr(ltrim($text, $quote), 0, -1); |
| 46 | $strings[$text] = $text; |
| 47 | } |
| 48 | return array_values($strings); |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * Identify duplicate, adjacent, identical closures and consolidate them. |
| 53 | * |
| 54 | * Note that you can only dedupe closures if they are directly adjacent and |
| 55 | * have exactly the same parameters. |
| 56 | * |
| 57 | * Also dedupes the "use strict" directive as it is only meaningful at the beginning of a closure. |
| 58 | * |
| 59 | * @param array $scripts |
| 60 | * Javascript source. |
| 61 | * @param array $localVars |
| 62 | * Ordered list of JS vars to identify the start of a closure. |
| 63 | * @param array $inputVals |
| 64 | * Ordered list of input values passed into the closure. |
| 65 | * @return string[] |
| 66 | * Javascript source. |
| 67 | */ |
| 68 | public static function dedupeClosures($scripts, $localVars, $inputVals) { |
| 69 | // Example opening: (function (angular, $, _) { |
| 70 | $opening = '\s*\(\s*function\s*\(\s*'; |
| 71 | $opening .= implode(',\s*', array_map(function ($v) { |
| 72 | return preg_quote($v, '/'); |
| 73 | }, $localVars)); |
| 74 | $opening .= '\)\s*\{'; |
| 75 | $opening = '/^' . $opening . '\s*(?:"use strict";\s|\'use strict\';\s)?/'; |
| 76 | |
| 77 | // Example closing: })(angular, CRM.$, CRM._); |
| 78 | $closing = '\}\s*\)\s*\(\s*'; |
| 79 | $closing .= implode(',\s*', array_map(function ($v) { |
| 80 | return preg_quote($v, '/'); |
| 81 | }, $inputVals)); |
| 82 | $closing .= '\);\s*'; |
| 83 | $closing = "/$closing\$/"; |
| 84 | |
| 85 | $scripts = array_values($scripts); |
| 86 | for ($i = count($scripts) - 1; $i > 0; $i--) { |
| 87 | if (preg_match($closing, $scripts[$i - 1]) && preg_match($opening, $scripts[$i])) { |
| 88 | $scripts[$i - 1] = preg_replace($closing, '', $scripts[$i - 1]); |
| 89 | $scripts[$i] = preg_replace($opening, '', $scripts[$i]); |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | return $scripts; |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * This is a primitive comment stripper. It doesn't catch all comments |
| 98 | * and falls short of minification, but it doesn't munge Angular injections |
| 99 | * and is fast enough to run synchronously (without caching). |
| 100 | * |
| 101 | * At time of writing, running this against the Angular modules, this impl |
| 102 | * of stripComments currently adds 10-20ms and cuts ~7%. |
| 103 | * |
| 104 | * Please be extremely cautious about extending this. If you want better |
| 105 | * minification, you should probably remove this implementation, |
| 106 | * import a proper JSMin implementation, and cache its output. |
| 107 | * |
| 108 | * @param string $script |
| 109 | * @return string |
| 110 | */ |
| 111 | public static function stripComments($script) { |
| 112 | // This function is a little naive, and some expressions may trip it up. Opt-out if anything smells fishy. |
| 113 | if (preg_match(';`\r?\n//;', $script)) { |
| 114 | return $script; |
| 115 | } |
| 116 | return preg_replace("#^\\s*//[^\n]*$(?:\r\n|\n)?#m", "", $script); |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * Decodes a js variable (not necessarily strict json but valid js) into a php variable. |
| 121 | * |
| 122 | * This is similar to using json_decode($js, TRUE) but more forgiving about syntax. |
| 123 | * |
| 124 | * ex. {a: 'Apple', 'b': "Banana", c: [1, 2, 3]} |
| 125 | * Returns: [ |
| 126 | * 'a' => 'Apple', |
| 127 | * 'b' => 'Banana', |
| 128 | * 'c' => [1, 2, 3], |
| 129 | * ] |
| 130 | * |
| 131 | * @param string $js |
| 132 | * @param bool $throwException |
| 133 | * @return mixed |
| 134 | * @throws CRM_Core_Exception |
| 135 | */ |
| 136 | public static function decode($js, $throwException = FALSE) { |
| 137 | $js = trim($js); |
| 138 | $first = substr($js, 0, 1); |
| 139 | $last = substr($js, -1); |
| 140 | if ($first === "'" && $last === "'") { |
| 141 | $js = self::convertSingleQuoteString($js, $throwException); |
| 142 | } |
| 143 | elseif (($first === '{' && $last === '}') || ($first === '[' && $last === ']')) { |
| 144 | $obj = self::getRawProps($js); |
| 145 | foreach ($obj as $idx => $item) { |
| 146 | $obj[$idx] = self::decode($item, $throwException); |
| 147 | } |
| 148 | return $obj; |
| 149 | } |
| 150 | $result = json_decode($js); |
| 151 | if ($throwException && $result === NULL && $js !== 'null') { |
| 152 | throw new CRM_Core_Exception(json_last_error_msg()); |
| 153 | } |
| 154 | return $result; |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * @param string $str |
| 159 | * @param bool $throwException |
| 160 | * @return string|null |
| 161 | * @throws CRM_Core_Exception |
| 162 | */ |
| 163 | public static function convertSingleQuoteString(string $str, $throwException) { |
| 164 | // json_decode can only handle double quotes around strings, so convert single-quoted strings |
| 165 | $backslash = chr(0) . 'backslash' . chr(0); |
| 166 | $str = str_replace(['\\\\', '\\"', '"', '\\&', '\\/', $backslash], [$backslash, '"', '\\"', '&', '/', '\\'], substr($str, 1, -1)); |
| 167 | // Ensure the string doesn't terminate early by checking that all single quotes are escaped |
| 168 | $pos = -1; |
| 169 | while (($pos = strpos($str, "'", $pos + 1)) !== FALSE) { |
| 170 | if (($pos - strlen(rtrim(substr($str, 0, $pos)))) % 2) { |
| 171 | if ($throwException) { |
| 172 | throw new CRM_Core_Exception('Invalid string passed to CRM_Utils_JS::decode'); |
| 173 | } |
| 174 | return NULL; |
| 175 | } |
| 176 | } |
| 177 | return '"' . $str . '"'; |
| 178 | } |
| 179 | |
| 180 | /** |
| 181 | * Encodes a variable to js notation (not strict json) suitable for e.g. an angular attribute. |
| 182 | * |
| 183 | * Like json_encode() but the output looks more like native javascript, |
| 184 | * with single quotes around strings and no unnecessary quotes around object keys. |
| 185 | * |
| 186 | * Ex input: [ |
| 187 | * 'a' => 'Apple', |
| 188 | * 'b' => 'Banana', |
| 189 | * 'c' => [1, 2, 3], |
| 190 | * ] |
| 191 | * Ex output: {a: 'Apple', b: 'Banana', c: [1, 2, 3]} |
| 192 | * |
| 193 | * @param mixed $value |
| 194 | * @return string |
| 195 | */ |
| 196 | public static function encode($value) { |
| 197 | if (is_array($value)) { |
| 198 | return self::writeObject($value, TRUE); |
| 199 | } |
| 200 | $result = json_encode($value, JSON_UNESCAPED_SLASHES); |
| 201 | // Convert double-quotes around string to single quotes |
| 202 | if (is_string($value) && substr($result, 0, 1) === '"' && substr($result, -1) === '"') { |
| 203 | $backslash = chr(0) . 'backslash' . chr(0); |
| 204 | return "'" . str_replace(['\\\\', '\\"', "'", $backslash], [$backslash, '"', "\\'", '\\\\'], substr($result, 1, -1)) . "'"; |
| 205 | } |
| 206 | return $result; |
| 207 | } |
| 208 | |
| 209 | /** |
| 210 | * Gets the properties of a javascript object/array WITHOUT decoding them. |
| 211 | * |
| 212 | * Useful when the object might contain js functions, expressions, etc. which cannot be decoded. |
| 213 | * Returns an array with keys as property names and values as raw strings of js. |
| 214 | * |
| 215 | * Ex Input: {foo: getFoo(arg), 'bar': function() {return "bar";}} |
| 216 | * Returns: [ |
| 217 | * 'foo' => 'getFoo(arg)', |
| 218 | * 'bar' => 'function() {return "bar";}', |
| 219 | * ] |
| 220 | * |
| 221 | * @param string $js |
| 222 | * @return array |
| 223 | * @throws Exception |
| 224 | */ |
| 225 | public static function getRawProps($js) { |
| 226 | $js = trim($js); |
| 227 | if (!is_string($js) || $js === '' || !($js[0] === '{' || $js[0] === '[')) { |
| 228 | throw new Exception("Invalid js object string passed to CRM_Utils_JS::getRawProps"); |
| 229 | } |
| 230 | $chars = str_split(substr($js, 1)); |
| 231 | $isEscaped = $quote = NULL; |
| 232 | $type = $js[0] === '{' ? 'object' : 'array'; |
| 233 | $key = $type == 'array' ? 0 : NULL; |
| 234 | $item = ''; |
| 235 | $end = strlen($js) - 2; |
| 236 | $quotes = ['"', "'", '/']; |
| 237 | $brackets = [ |
| 238 | '}' => '{', |
| 239 | ')' => '(', |
| 240 | ']' => '[', |
| 241 | ':' => '?', |
| 242 | ]; |
| 243 | $enclosures = array_fill_keys($brackets, 0); |
| 244 | $result = []; |
| 245 | foreach ($chars as $index => $char) { |
| 246 | if (!$isEscaped && in_array($char, $quotes, TRUE)) { |
| 247 | // Open quotes, taking care not to mistake the division symbol for opening a regex |
| 248 | if (!$quote && !($char == '/' && preg_match('{[\w)]\s*$}', $item))) { |
| 249 | $quote = $char; |
| 250 | } |
| 251 | // Close quotes |
| 252 | elseif ($char === $quote) { |
| 253 | $quote = NULL; |
| 254 | } |
| 255 | } |
| 256 | if (!$quote) { |
| 257 | // Delineates property key |
| 258 | if ($char == ':' && !array_filter($enclosures) && !$key) { |
| 259 | $key = $item; |
| 260 | $item = ''; |
| 261 | continue; |
| 262 | } |
| 263 | // Delineates property value |
| 264 | if (($char == ',' || $index == $end) && !array_filter($enclosures) && isset($key) && trim($item) !== '') { |
| 265 | // Trim, unquote, and unescape characters in key |
| 266 | if ($type == 'object') { |
| 267 | $key = trim($key); |
| 268 | $key = in_array($key[0], $quotes) ? self::decode($key) : $key; |
| 269 | } |
| 270 | $result[$key] = trim($item); |
| 271 | $key = $type == 'array' ? $key + 1 : NULL; |
| 272 | $item = ''; |
| 273 | continue; |
| 274 | } |
| 275 | // Open brackets - we'll ignore delineators inside |
| 276 | if (isset($enclosures[$char])) { |
| 277 | $enclosures[$char]++; |
| 278 | } |
| 279 | // Close brackets |
| 280 | if (isset($brackets[$char]) && $enclosures[$brackets[$char]]) { |
| 281 | $enclosures[$brackets[$char]]--; |
| 282 | } |
| 283 | } |
| 284 | $item .= $char; |
| 285 | // We are escaping the next char if this is a backslash not preceded by an odd number of backslashes |
| 286 | $isEscaped = $char === '\\' && ((strlen($item) - strlen(rtrim($item, '\\'))) % 2); |
| 287 | } |
| 288 | return $result; |
| 289 | } |
| 290 | |
| 291 | /** |
| 292 | * Converts a php array to javascript object/array notation (not strict JSON). |
| 293 | * |
| 294 | * Does not encode keys unless they contain special characters. |
| 295 | * Does not encode values by default, so either specify $encodeValues = TRUE, |
| 296 | * or pass strings of valid js/json as values (per output from getRawProps). |
| 297 | * @see CRM_Utils_JS::getRawProps |
| 298 | * |
| 299 | * @param array $obj |
| 300 | * @param bool $encodeValues |
| 301 | * @return string |
| 302 | */ |
| 303 | public static function writeObject($obj, $encodeValues = FALSE) { |
| 304 | $js = []; |
| 305 | $brackets = isset($obj[0]) && array_keys($obj) === range(0, count($obj) - 1) ? ['[', ']'] : ['{', '}']; |
| 306 | foreach ($obj as $key => $val) { |
| 307 | if ($encodeValues) { |
| 308 | $val = self::encode($val); |
| 309 | } |
| 310 | if ($brackets[0] == '{') { |
| 311 | // Enclose the key in quotes unless it is purely alphanumeric |
| 312 | if (preg_match('/\W/', $key)) { |
| 313 | // Prefer single quotes |
| 314 | $key = preg_match('/^[\w "]+$/', $key) ? "'" . $key . "'" : json_encode($key, JSON_UNESCAPED_SLASHES); |
| 315 | } |
| 316 | $js[] = "$key: $val"; |
| 317 | } |
| 318 | else { |
| 319 | $js[] = $val; |
| 320 | } |
| 321 | } |
| 322 | return $brackets[0] . implode(', ', $js) . $brackets[1]; |
| 323 | } |
| 324 | |
| 325 | } |