| 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 | * @param array $scripts |
| 58 | * Javascript source. |
| 59 | * @param array $localVars |
| 60 | * Ordered list of JS vars to identify the start of a closure. |
| 61 | * @param array $inputVals |
| 62 | * Ordered list of input values passed into the closure. |
| 63 | * @return string |
| 64 | * Javascript source. |
| 65 | */ |
| 66 | public static function dedupeClosures($scripts, $localVars, $inputVals) { |
| 67 | // Example opening: (function (angular, $, _) { |
| 68 | $opening = '\s*\(\s*function\s*\(\s*'; |
| 69 | $opening .= implode(',\s*', array_map(function ($v) { |
| 70 | return preg_quote($v, '/'); |
| 71 | }, $localVars)); |
| 72 | $opening .= '\)\s*\{'; |
| 73 | $opening = '/^' . $opening . '/'; |
| 74 | |
| 75 | // Example closing: })(angular, CRM.$, CRM._); |
| 76 | $closing = '\}\s*\)\s*\(\s*'; |
| 77 | $closing .= implode(',\s*', array_map(function ($v) { |
| 78 | return preg_quote($v, '/'); |
| 79 | }, $inputVals)); |
| 80 | $closing .= '\);\s*'; |
| 81 | $closing = "/$closing\$/"; |
| 82 | |
| 83 | $scripts = array_values($scripts); |
| 84 | for ($i = count($scripts) - 1; $i > 0; $i--) { |
| 85 | if (preg_match($closing, $scripts[$i - 1]) && preg_match($opening, $scripts[$i])) { |
| 86 | $scripts[$i - 1] = preg_replace($closing, '', $scripts[$i - 1]); |
| 87 | $scripts[$i] = preg_replace($opening, '', $scripts[$i]); |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | return $scripts; |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * This is a primitive comment stripper. It doesn't catch all comments |
| 96 | * and falls short of minification, but it doesn't munge Angular injections |
| 97 | * and is fast enough to run synchronously (without caching). |
| 98 | * |
| 99 | * At time of writing, running this against the Angular modules, this impl |
| 100 | * of stripComments currently adds 10-20ms and cuts ~7%. |
| 101 | * |
| 102 | * Please be extremely cautious about extending this. If you want better |
| 103 | * minification, you should probably remove this implementation, |
| 104 | * import a proper JSMin implementation, and cache its output. |
| 105 | * |
| 106 | * @param string $script |
| 107 | * @return string |
| 108 | */ |
| 109 | public static function stripComments($script) { |
| 110 | return preg_replace(":^\\s*//[^\n]+$:m", "", $script); |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * Decodes a js variable (not necessarily strict json but valid js) into a php variable. |
| 115 | * |
| 116 | * This is similar to using json_decode($js, TRUE) but more forgiving about syntax. |
| 117 | * |
| 118 | * ex. {a: 'Apple', 'b': "Banana", c: [1, 2, 3]} |
| 119 | * Returns: [ |
| 120 | * 'a' => 'Apple', |
| 121 | * 'b' => 'Banana', |
| 122 | * 'c' => [1, 2, 3], |
| 123 | * ] |
| 124 | * |
| 125 | * @param string $js |
| 126 | * @return mixed |
| 127 | */ |
| 128 | public static function decode($js) { |
| 129 | $js = trim($js); |
| 130 | $first = substr($js, 0, 1); |
| 131 | $last = substr($js, -1); |
| 132 | if ($last === $first && ($first === "'" || $first === '"')) { |
| 133 | // Use a temp placeholder for escaped backslashes |
| 134 | $backslash = chr(0) . 'backslash' . chr(0); |
| 135 | return str_replace(['\\\\', "\\'", '\\"', '\\&', '\\/', $backslash], [$backslash, "'", '"', '&', '/', '\\'], substr($js, 1, -1)); |
| 136 | } |
| 137 | if (($first === '{' && $last === '}') || ($first === '[' && $last === ']')) { |
| 138 | $obj = self::getRawProps($js); |
| 139 | foreach ($obj as $idx => $item) { |
| 140 | $obj[$idx] = self::decode($item); |
| 141 | } |
| 142 | return $obj; |
| 143 | } |
| 144 | return json_decode($js); |
| 145 | } |
| 146 | |
| 147 | /** |
| 148 | * Encodes a variable to js notation (not strict json) suitable for e.g. an angular attribute. |
| 149 | * |
| 150 | * Like json_encode() but the output looks more like native javascript, |
| 151 | * with single quotes around strings and no unnecessary quotes around object keys. |
| 152 | * |
| 153 | * Ex input: [ |
| 154 | * 'a' => 'Apple', |
| 155 | * 'b' => 'Banana', |
| 156 | * 'c' => [1, 2, 3], |
| 157 | * ] |
| 158 | * Ex output: {a: 'Apple', b: 'Banana', c: [1, 2, 3]} |
| 159 | * |
| 160 | * @param mixed $value |
| 161 | * @return string |
| 162 | */ |
| 163 | public static function encode($value) { |
| 164 | if (is_array($value)) { |
| 165 | return self::writeObject($value, TRUE); |
| 166 | } |
| 167 | $result = json_encode($value, JSON_UNESCAPED_SLASHES); |
| 168 | // Convert double-quotes around string to single quotes |
| 169 | if (is_string($value) && substr($result, 0, 1) === '"' && substr($result, -1) === '"') { |
| 170 | $backslash = chr(0) . 'backslash' . chr(0); |
| 171 | return "'" . str_replace(['\\\\', '\\"', "'", $backslash], [$backslash, '"', "\\'", '\\\\'], substr($result, 1, -1)) . "'"; |
| 172 | } |
| 173 | return $result; |
| 174 | } |
| 175 | |
| 176 | /** |
| 177 | * Gets the properties of a javascript object/array WITHOUT decoding them. |
| 178 | * |
| 179 | * Useful when the object might contain js functions, expressions, etc. which cannot be decoded. |
| 180 | * Returns an array with keys as property names and values as raw strings of js. |
| 181 | * |
| 182 | * Ex Input: {foo: getFoo(arg), 'bar': function() {return "bar";}} |
| 183 | * Returns: [ |
| 184 | * 'foo' => 'getFoo(arg)', |
| 185 | * 'bar' => 'function() {return "bar";}', |
| 186 | * ] |
| 187 | * |
| 188 | * @param $js |
| 189 | * @return array |
| 190 | * @throws \Exception |
| 191 | */ |
| 192 | public static function getRawProps($js) { |
| 193 | $js = trim($js); |
| 194 | if (!is_string($js) || $js === '' || !($js[0] === '{' || $js[0] === '[')) { |
| 195 | throw new Exception("Invalid js object string passed to CRM_Utils_JS::getRawProps"); |
| 196 | } |
| 197 | $chars = str_split(substr($js, 1)); |
| 198 | $isEscaped = $quote = NULL; |
| 199 | $type = $js[0] === '{' ? 'object' : 'array'; |
| 200 | $key = $type == 'array' ? 0 : NULL; |
| 201 | $item = ''; |
| 202 | $end = strlen($js) - 2; |
| 203 | $quotes = ['"', "'", '/']; |
| 204 | $brackets = [ |
| 205 | '}' => '{', |
| 206 | ')' => '(', |
| 207 | ']' => '[', |
| 208 | ':' => '?', |
| 209 | ]; |
| 210 | $enclosures = array_fill_keys($brackets, 0); |
| 211 | $result = []; |
| 212 | foreach ($chars as $index => $char) { |
| 213 | if (!$isEscaped && in_array($char, $quotes, TRUE)) { |
| 214 | // Open quotes, taking care not to mistake the division symbol for opening a regex |
| 215 | if (!$quote && !($char == '/' && preg_match('{[\w)]\s*$}', $item))) { |
| 216 | $quote = $char; |
| 217 | } |
| 218 | // Close quotes |
| 219 | elseif ($char === $quote) { |
| 220 | $quote = NULL; |
| 221 | } |
| 222 | } |
| 223 | if (!$quote) { |
| 224 | // Delineates property key |
| 225 | if ($char == ':' && !array_filter($enclosures) && !$key) { |
| 226 | $key = $item; |
| 227 | $item = ''; |
| 228 | continue; |
| 229 | } |
| 230 | // Delineates property value |
| 231 | if (($char == ',' || $index == $end) && !array_filter($enclosures) && isset($key) && trim($item) !== '') { |
| 232 | // Trim, unquote, and unescape characters in key |
| 233 | if ($type == 'object') { |
| 234 | $key = trim($key); |
| 235 | $key = in_array($key[0], $quotes) ? self::decode($key) : $key; |
| 236 | } |
| 237 | $result[$key] = trim($item); |
| 238 | $key = $type == 'array' ? $key + 1 : NULL; |
| 239 | $item = ''; |
| 240 | continue; |
| 241 | } |
| 242 | // Open brackets - we'll ignore delineators inside |
| 243 | if (isset($enclosures[$char])) { |
| 244 | $enclosures[$char]++; |
| 245 | } |
| 246 | // Close brackets |
| 247 | if (isset($brackets[$char]) && $enclosures[$brackets[$char]]) { |
| 248 | $enclosures[$brackets[$char]]--; |
| 249 | } |
| 250 | } |
| 251 | $item .= $char; |
| 252 | // We are escaping the next char if this is a backslash not preceded by an odd number of backslashes |
| 253 | $isEscaped = $char === '\\' && ((strlen($item) - strlen(rtrim($item, '\\'))) % 2); |
| 254 | } |
| 255 | return $result; |
| 256 | } |
| 257 | |
| 258 | /** |
| 259 | * Converts a php array to javascript object/array notation (not strict JSON). |
| 260 | * |
| 261 | * Does not encode keys unless they contain special characters. |
| 262 | * Does not encode values by default, so either specify $encodeValues = TRUE, |
| 263 | * or pass strings of valid js/json as values (per output from getRawProps). |
| 264 | * @see CRM_Utils_JS::getRawProps |
| 265 | * |
| 266 | * @param array $obj |
| 267 | * @param bool $encodeValues |
| 268 | * @return string |
| 269 | */ |
| 270 | public static function writeObject($obj, $encodeValues = FALSE) { |
| 271 | $js = []; |
| 272 | $brackets = isset($obj[0]) && array_keys($obj) === range(0, count($obj) - 1) ? ['[', ']'] : ['{', '}']; |
| 273 | foreach ($obj as $key => $val) { |
| 274 | if ($encodeValues) { |
| 275 | $val = self::encode($val); |
| 276 | } |
| 277 | if ($brackets[0] == '{') { |
| 278 | // Enclose the key in quotes unless it is purely alphanumeric |
| 279 | if (preg_match('/\W/', $key)) { |
| 280 | // Prefer single quotes |
| 281 | $key = preg_match('/^[\w "]+$/', $key) ? "'" . $key . "'" : json_encode($key, JSON_UNESCAPED_SLASHES); |
| 282 | } |
| 283 | $js[] = "$key: $val"; |
| 284 | } |
| 285 | else { |
| 286 | $js[] = $val; |
| 287 | } |
| 288 | } |
| 289 | return $brackets[0] . implode(', ', $js) . $brackets[1]; |
| 290 | } |
| 291 | |
| 292 | } |