Commit | Line | Data |
---|---|---|
6a488035 | 1 | <?php |
6a488035 | 2 | /* |
bc77d7c0 TO |
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 | +--------------------------------------------------------------------+ | |
e70a7fc0 | 10 | */ |
6a488035 TO |
11 | |
12 | /** | |
13 | * Parse Javascript content and extract translatable strings. | |
14 | * | |
15 | * @package CRM | |
ca5cec67 | 16 | * @copyright CiviCRM LLC https://civicrm.org/licensing |
6a488035 TO |
17 | */ |
18 | class CRM_Utils_JS { | |
6714d8d2 | 19 | |
6a488035 | 20 | /** |
fe482240 | 21 | * Parse a javascript file for translatable strings. |
6a488035 | 22 | * |
77855840 TO |
23 | * @param string $jsCode |
24 | * Raw Javascript code. | |
a6c01b45 | 25 | * @return array |
16b10e64 | 26 | * Array of translatable strings |
6a488035 TO |
27 | */ |
28 | public static function parseStrings($jsCode) { | |
be2fb01f | 29 | $strings = []; |
6a488035 TO |
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 | } | |
96025800 | 50 | |
ad295ca9 TO |
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 | * | |
df4b6c3f CW |
57 | * Also dedupes the "use strict" directive as it is only meaningful at the beginning of a closure. |
58 | * | |
ad295ca9 TO |
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. | |
127d3501 | 65 | * @return string[] |
ad295ca9 TO |
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*\{'; | |
df4b6c3f | 75 | $opening = '/^' . $opening . '\s*(?:"use strict";\s|\'use strict\';\s)?/'; |
ad295ca9 TO |
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 | ||
b047e061 TO |
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) { | |
e521c158 TO |
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 | } | |
0290b3eb | 116 | return preg_replace("#^\\s*//[^\n]*$(?:\r\n|\n)?#m", "", $script); |
b047e061 TO |
117 | } |
118 | ||
a49c5ad6 | 119 | /** |
9511ca30 CW |
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. | |
a49c5ad6 CW |
123 | * |
124 | * ex. {a: 'Apple', 'b': "Banana", c: [1, 2, 3]} | |
9511ca30 CW |
125 | * Returns: [ |
126 | * 'a' => 'Apple', | |
127 | * 'b' => 'Banana', | |
128 | * 'c' => [1, 2, 3], | |
129 | * ] | |
a49c5ad6 CW |
130 | * |
131 | * @param string $js | |
1ae350ce | 132 | * @param bool $throwException |
a49c5ad6 | 133 | * @return mixed |
1ae350ce | 134 | * @throws CRM_Core_Exception |
a49c5ad6 | 135 | */ |
1ae350ce | 136 | public static function decode($js, $throwException = FALSE) { |
9511ca30 | 137 | $js = trim($js); |
d9c7a051 CW |
138 | $first = substr($js, 0, 1); |
139 | $last = substr($js, -1); | |
1ae350ce CW |
140 | if ($first === "'" && $last === "'") { |
141 | $js = self::convertSingleQuoteString($js, $throwException); | |
a49c5ad6 | 142 | } |
1ae350ce | 143 | elseif (($first === '{' && $last === '}') || ($first === '[' && $last === ']')) { |
9511ca30 CW |
144 | $obj = self::getRawProps($js); |
145 | foreach ($obj as $idx => $item) { | |
1ae350ce | 146 | $obj[$idx] = self::decode($item, $throwException); |
9511ca30 CW |
147 | } |
148 | return $obj; | |
149 | } | |
1ae350ce CW |
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 | |
4658b535 | 159 | * @param bool $throwException |
1ae350ce CW |
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 . '"'; | |
a49c5ad6 CW |
178 | } |
179 | ||
89b24877 | 180 | /** |
10515677 | 181 | * Encodes a variable to js notation (not strict json) suitable for e.g. an angular attribute. |
89b24877 | 182 | * |
10515677 CW |
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. | |
89b24877 | 185 | * |
10515677 CW |
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 | |
89b24877 CW |
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); | |
10515677 CW |
201 | // Convert double-quotes around string to single quotes |
202 | if (is_string($value) && substr($result, 0, 1) === '"' && substr($result, -1) === '"') { | |
3807fa18 CW |
203 | $backslash = chr(0) . 'backslash' . chr(0); |
204 | return "'" . str_replace(['\\\\', '\\"', "'", $backslash], [$backslash, '"', "\\'", '\\\\'], substr($result, 1, -1)) . "'"; | |
89b24877 CW |
205 | } |
206 | return $result; | |
207 | } | |
208 | ||
3203414a | 209 | /** |
9511ca30 | 210 | * Gets the properties of a javascript object/array WITHOUT decoding them. |
3203414a CW |
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 | * | |
9511ca30 CW |
215 | * Ex Input: {foo: getFoo(arg), 'bar': function() {return "bar";}} |
216 | * Returns: [ | |
3203414a | 217 | * 'foo' => 'getFoo(arg)', |
9511ca30 | 218 | * 'bar' => 'function() {return "bar";}', |
3203414a CW |
219 | * ] |
220 | * | |
221 | * @param $js | |
222 | * @return array | |
127d3501 | 223 | * @throws Exception |
3203414a | 224 | */ |
9511ca30 | 225 | public static function getRawProps($js) { |
3203414a | 226 | $js = trim($js); |
9511ca30 CW |
227 | if (!is_string($js) || $js === '' || !($js[0] === '{' || $js[0] === '[')) { |
228 | throw new Exception("Invalid js object string passed to CRM_Utils_JS::getRawProps"); | |
3203414a CW |
229 | } |
230 | $chars = str_split(substr($js, 1)); | |
9511ca30 CW |
231 | $isEscaped = $quote = NULL; |
232 | $type = $js[0] === '{' ? 'object' : 'array'; | |
233 | $key = $type == 'array' ? 0 : NULL; | |
3203414a | 234 | $item = ''; |
9511ca30 CW |
235 | $end = strlen($js) - 2; |
236 | $quotes = ['"', "'", '/']; | |
237 | $brackets = [ | |
3203414a CW |
238 | '}' => '{', |
239 | ')' => '(', | |
240 | ']' => '[', | |
9511ca30 | 241 | ':' => '?', |
3203414a | 242 | ]; |
9511ca30 | 243 | $enclosures = array_fill_keys($brackets, 0); |
3203414a CW |
244 | $result = []; |
245 | foreach ($chars as $index => $char) { | |
9511ca30 CW |
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 | } | |
3203414a CW |
255 | } |
256 | if (!$quote) { | |
3203414a | 257 | // Delineates property key |
9511ca30 | 258 | if ($char == ':' && !array_filter($enclosures) && !$key) { |
3203414a CW |
259 | $key = $item; |
260 | $item = ''; | |
3203414a CW |
261 | continue; |
262 | } | |
263 | // Delineates property value | |
9511ca30 CW |
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; | |
3203414a | 272 | $item = ''; |
3203414a CW |
273 | continue; |
274 | } | |
275 | // Open brackets - we'll ignore delineators inside | |
276 | if (isset($enclosures[$char])) { | |
277 | $enclosures[$char]++; | |
278 | } | |
279 | // Close brackets | |
9511ca30 CW |
280 | if (isset($brackets[$char]) && $enclosures[$brackets[$char]]) { |
281 | $enclosures[$brackets[$char]]--; | |
3203414a CW |
282 | } |
283 | } | |
284 | $item .= $char; | |
9511ca30 CW |
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); | |
3203414a CW |
287 | } |
288 | return $result; | |
289 | } | |
290 | ||
9511ca30 CW |
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) { | |
89b24877 | 308 | $val = self::encode($val); |
9511ca30 CW |
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 | ||
232624b1 | 325 | } |