1
|
|
|
<?php |
2
|
|
|
/** @noinspection PhpComposerExtensionStubsInspection */ |
3
|
|
|
|
4
|
|
|
namespace Gettext\Extractors; |
5
|
|
|
|
6
|
|
|
use DOMAttr; |
7
|
|
|
use DOMDocument; |
8
|
|
|
use DOMElement; |
9
|
|
|
use DOMNode; |
10
|
|
|
use Exception; |
11
|
|
|
use Gettext\Translations; |
12
|
|
|
use Gettext\Utils\JsFunctionsScanner; |
13
|
|
|
|
14
|
|
|
/** |
15
|
|
|
* Class to get gettext strings from VueJS template files. |
16
|
|
|
*/ |
17
|
|
|
class VueJs extends Extractor implements ExtractorInterface, ExtractorMultiInterface |
18
|
|
|
{ |
19
|
|
|
public static $options = [ |
20
|
|
|
'constants' => [], |
21
|
|
|
|
22
|
|
|
'functions' => [ |
23
|
|
|
'gettext' => 'gettext', |
24
|
|
|
'__' => 'gettext', |
25
|
|
|
'ngettext' => 'ngettext', |
26
|
|
|
'n__' => 'ngettext', |
27
|
|
|
'pgettext' => 'pgettext', |
28
|
|
|
'p__' => 'pgettext', |
29
|
|
|
'dgettext' => 'dgettext', |
30
|
|
|
'd__' => 'dgettext', |
31
|
|
|
'dngettext' => 'dngettext', |
32
|
|
|
'dn__' => 'dngettext', |
33
|
|
|
'dpgettext' => 'dpgettext', |
34
|
|
|
'dp__' => 'dpgettext', |
35
|
|
|
'npgettext' => 'npgettext', |
36
|
|
|
'np__' => 'npgettext', |
37
|
|
|
'dnpgettext' => 'dnpgettext', |
38
|
|
|
'dnp__' => 'dnpgettext', |
39
|
|
|
'noop' => 'noop', |
40
|
|
|
'noop__' => 'noop', |
41
|
|
|
], |
42
|
|
|
]; |
43
|
|
|
|
44
|
|
|
/** |
45
|
|
|
* @inheritDoc |
46
|
|
|
* @throws Exception |
47
|
|
|
*/ |
48
|
|
View Code Duplication |
public static function fromFileMultiple($file, array $translations, array $options = []) |
|
|
|
|
49
|
|
|
{ |
50
|
|
|
foreach (self::getFiles($file) as $file) { |
51
|
|
|
$options['file'] = $file; |
52
|
|
|
static::fromStringMultiple(self::readFile($file), $translations, $options); |
53
|
|
|
} |
54
|
|
|
} |
55
|
|
|
|
56
|
|
|
/** |
57
|
|
|
* @inheritdoc |
58
|
|
|
* @throws Exception |
59
|
|
|
*/ |
60
|
|
|
public static function fromString($string, Translations $translations, array $options = []) |
61
|
|
|
{ |
62
|
|
|
self::fromStringMultiple($string, [$translations], $options); |
63
|
|
|
} |
64
|
|
|
|
65
|
|
|
/** |
66
|
|
|
* @inheritDoc |
67
|
|
|
* @throws Exception |
68
|
|
|
*/ |
69
|
|
|
public static function fromStringMultiple($string, array $translations, array $options = []) |
70
|
|
|
{ |
71
|
|
|
$options += self::$options; |
72
|
|
|
$options += [ |
73
|
|
|
// HTML attribute prefixes we parse as JS which could contain translations (are JS expressions) |
74
|
|
|
'attributePrefixes' => [ |
75
|
|
|
':', |
76
|
|
|
'v-bind:', |
77
|
|
|
'v-on:', |
78
|
|
|
'v-text', |
79
|
|
|
], |
80
|
|
|
// HTML Tags to parse |
81
|
|
|
'tagNames' => [ |
82
|
|
|
'translate', |
83
|
|
|
], |
84
|
|
|
// HTML tags to parse when attribute exists |
85
|
|
|
'tagAttributes' => [ |
86
|
|
|
'v-translate', |
87
|
|
|
], |
88
|
|
|
// Comments |
89
|
|
|
'commentAttributes' => [ |
90
|
|
|
'translate-comment', |
91
|
|
|
], |
92
|
|
|
'contextAttributes' => [ |
93
|
|
|
'translate-context', |
94
|
|
|
], |
95
|
|
|
// Attribute with plural content |
96
|
|
|
'pluralAttributes' => [ |
97
|
|
|
'translate-plural', |
98
|
|
|
], |
99
|
|
|
]; |
100
|
|
|
|
101
|
|
|
// Ok, this is the weirdest hack, but let me explain: |
102
|
|
|
// On Linux (Mac is fine), when converting HTML to DOM, new lines get trimmed after the first tag. |
103
|
|
|
// So if there are new lines between <template> and next element, they are lost |
104
|
|
|
// So we insert a "." which is a text node, and it will prevent that newlines are stripped between elements. |
105
|
|
|
// Same thing happens between template and script tag. |
106
|
|
|
$string = str_replace('<template>', '<template>.', $string); |
107
|
|
|
$string = str_replace('</template>', '</template>.', $string); |
108
|
|
|
|
109
|
|
|
// Normalize newlines |
110
|
|
|
$string = str_replace(["\r\n", "\n\r", "\r"], "\n", $string); |
111
|
|
|
|
112
|
|
|
// VueJS files are valid HTML files, we will operate with the DOM here |
113
|
|
|
$dom = self::convertHtmlToDom($string); |
114
|
|
|
|
115
|
|
|
$script = self::extractScriptTag($string); |
116
|
|
|
|
117
|
|
|
// Parse the script part as a regular JS code |
118
|
|
|
if ($script) { |
119
|
|
|
$scriptLineNumber = $dom->getElementsByTagName('script')->item(0)->getLineNo(); |
120
|
|
|
self::getScriptTranslationsFromString( |
121
|
|
|
$script, |
|
|
|
|
122
|
|
|
$translations, |
123
|
|
|
$options, |
124
|
|
|
$scriptLineNumber - 1 |
125
|
|
|
); |
126
|
|
|
} |
127
|
|
|
|
128
|
|
|
// Template part is parsed separately, all variables will be extracted |
129
|
|
|
// and handled as a regular JS code |
130
|
|
|
$template = $dom->getElementsByTagName('template')->item(0); |
131
|
|
|
if ($template) { |
132
|
|
|
self::getTemplateTranslations( |
133
|
|
|
$template, |
134
|
|
|
$translations, |
135
|
|
|
$options, |
136
|
|
|
$template->getLineNo() - 1 |
137
|
|
|
); |
138
|
|
|
} |
139
|
|
|
} |
140
|
|
|
|
141
|
|
|
/** |
142
|
|
|
* Extracts script tag contents using regex instead of DOM operations. |
143
|
|
|
* If we parse using DOM, some contents may change, for example, tags within strings will be stripped |
144
|
|
|
* |
145
|
|
|
* @param $string |
146
|
|
|
* @return bool|string |
147
|
|
|
*/ |
148
|
|
|
private static function extractScriptTag($string) |
149
|
|
|
{ |
150
|
|
|
if (preg_match('#<\s*?script\b[^>]*>(.*?)</script\b[^>]*>#s', $string, $matches)) { |
151
|
|
|
return $matches[1]; |
152
|
|
|
} |
153
|
|
|
|
154
|
|
|
return ''; |
155
|
|
|
} |
156
|
|
|
|
157
|
|
|
/** |
158
|
|
|
* @param string $html |
159
|
|
|
* @return DOMDocument |
160
|
|
|
*/ |
161
|
|
|
private static function convertHtmlToDom($html) |
162
|
|
|
{ |
163
|
|
|
$dom = new DOMDocument; |
164
|
|
|
|
165
|
|
|
libxml_use_internal_errors(true); |
166
|
|
|
$dom->loadHTML($html); |
167
|
|
|
|
168
|
|
|
libxml_clear_errors(); |
169
|
|
|
|
170
|
|
|
return $dom; |
171
|
|
|
} |
172
|
|
|
|
173
|
|
|
/** |
174
|
|
|
* Extract translations from script part |
175
|
|
|
* |
176
|
|
|
* @param string $scriptContents Only script tag contents, not the whole template |
177
|
|
|
* @param Translations|Translations[] $translations One or multiple domain Translation objects |
178
|
|
|
* @param array $options |
179
|
|
|
* @param int $lineOffset Number of lines the script is offset in the vue template file |
180
|
|
|
* @throws Exception |
181
|
|
|
*/ |
182
|
|
|
private static function getScriptTranslationsFromString( |
183
|
|
|
$scriptContents, |
184
|
|
|
$translations, |
185
|
|
|
array $options = [], |
186
|
|
|
$lineOffset = 0 |
187
|
|
|
) { |
188
|
|
|
$functions = new JsFunctionsScanner($scriptContents); |
189
|
|
|
$options['lineOffset'] = $lineOffset; |
190
|
|
|
$functions->saveGettextFunctions($translations, $options); |
191
|
|
|
} |
192
|
|
|
|
193
|
|
|
/** |
194
|
|
|
* Parse template to extract all translations (element content and dynamic element attributes) |
195
|
|
|
* |
196
|
|
|
* @param DOMNode $dom |
197
|
|
|
* @param Translations|Translations[] $translations One or multiple domain Translation objects |
198
|
|
|
* @param array $options |
199
|
|
|
* @param int $lineOffset Line number where the template part starts in the vue file |
200
|
|
|
* @throws Exception |
201
|
|
|
*/ |
202
|
|
|
private static function getTemplateTranslations( |
203
|
|
|
DOMNode $dom, |
204
|
|
|
$translations, |
205
|
|
|
array $options, |
206
|
|
|
$lineOffset = 0 |
207
|
|
|
) { |
208
|
|
|
// Build a JS string from all template attribute expressions |
209
|
|
|
$fakeAttributeJs = self::getTemplateAttributeFakeJs($options, $dom); |
210
|
|
|
|
211
|
|
|
// 1 line offset is necessary because parent template element was ignored when converting to DOM |
212
|
|
|
self::getScriptTranslationsFromString($fakeAttributeJs, $translations, $options, $lineOffset); |
213
|
|
|
|
214
|
|
|
// Build a JS string from template element content expressions |
215
|
|
|
$fakeTemplateJs = self::getTemplateFakeJs($dom); |
216
|
|
|
self::getScriptTranslationsFromString($fakeTemplateJs, $translations, $options, $lineOffset); |
217
|
|
|
|
218
|
|
|
self::getTagTranslations($options, $dom, $translations); |
219
|
|
|
} |
220
|
|
|
|
221
|
|
|
/** |
222
|
|
|
* @param array $options |
223
|
|
|
* @param DOMNode $dom |
224
|
|
|
* @param Translations|Translations[] $translations |
225
|
|
|
*/ |
226
|
|
|
private static function getTagTranslations(array $options, DOMNode $dom, $translations) |
227
|
|
|
{ |
228
|
|
|
// Since tag scanning does not support domains, we always use the first translation given |
229
|
|
|
$translations = is_array($translations) ? reset($translations) : $translations; |
230
|
|
|
|
231
|
|
|
$children = $dom->childNodes; |
232
|
|
|
for ($i = 0; $i < $children->length; $i++) { |
233
|
|
|
$node = $children->item($i); |
234
|
|
|
|
235
|
|
|
if (!($node instanceof DOMElement)) { |
236
|
|
|
continue; |
237
|
|
|
} |
238
|
|
|
|
239
|
|
|
$translatable = false; |
240
|
|
|
|
241
|
|
|
if (in_array($node->tagName, $options['tagNames'], true)) { |
242
|
|
|
$translatable = true; |
243
|
|
|
} |
244
|
|
|
|
245
|
|
|
$attrList = $node->attributes; |
246
|
|
|
$context = null; |
247
|
|
|
$plural = ""; |
248
|
|
|
$comment = null; |
249
|
|
|
|
250
|
|
|
for ($j = 0; $j < $attrList->length; $j++) { |
|
|
|
|
251
|
|
|
/** @var DOMAttr $domAttr */ |
252
|
|
|
$domAttr = $attrList->item($j); |
253
|
|
|
// Check if this is a dynamic vue attribute |
254
|
|
|
if (in_array($domAttr->name, $options['tagAttributes'])) { |
255
|
|
|
$translatable = true; |
256
|
|
|
} |
257
|
|
|
if (in_array($domAttr->name, $options['contextAttributes'])) { |
258
|
|
|
$context = $domAttr->value; |
259
|
|
|
} |
260
|
|
|
if (in_array($domAttr->name, $options['pluralAttributes'])) { |
261
|
|
|
$plural = $domAttr->value; |
262
|
|
|
} |
263
|
|
|
if (in_array($domAttr->name, $options['commentAttributes'])) { |
264
|
|
|
$comment = $domAttr->value; |
265
|
|
|
} |
266
|
|
|
} |
267
|
|
|
|
268
|
|
|
if ($translatable) { |
269
|
|
|
$translation = $translations->insert($context, trim($node->textContent), $plural); |
270
|
|
|
$translation->addReference($options['file'], $node->getLineNo()); |
271
|
|
|
if ($comment) { |
|
|
|
|
272
|
|
|
$translation->addExtractedComment($comment); |
273
|
|
|
} |
274
|
|
|
} |
275
|
|
|
|
276
|
|
|
if ($node->hasChildNodes()) { |
277
|
|
|
self::getTagTranslations($options, $node, $translations); |
278
|
|
|
} |
279
|
|
|
} |
280
|
|
|
} |
281
|
|
|
|
282
|
|
|
/** |
283
|
|
|
* Extract JS expressions from element attribute bindings (excluding text within elements) |
284
|
|
|
* For example: <span :title="__('extract this')"> skip element content </span> |
285
|
|
|
* |
286
|
|
|
* @param array $options |
287
|
|
|
* @param DOMNode $dom |
288
|
|
|
* @return string JS code |
289
|
|
|
*/ |
290
|
|
|
private static function getTemplateAttributeFakeJs(array $options, DOMNode $dom) |
291
|
|
|
{ |
292
|
|
|
$expressionsByLine = self::getVueAttributeExpressions($options['attributePrefixes'], $dom); |
293
|
|
|
|
294
|
|
|
if (empty($expressionsByLine)) { |
295
|
|
|
return ''; |
296
|
|
|
} |
297
|
|
|
|
298
|
|
|
$maxLines = max(array_keys($expressionsByLine)); |
299
|
|
|
$fakeJs = ''; |
300
|
|
|
|
301
|
|
|
for ($line = 1; $line <= $maxLines; $line++) { |
302
|
|
|
if (isset($expressionsByLine[$line])) { |
303
|
|
|
$fakeJs .= implode("; ", $expressionsByLine[$line]); |
304
|
|
|
} |
305
|
|
|
$fakeJs .= "\n"; |
306
|
|
|
} |
307
|
|
|
|
308
|
|
|
return $fakeJs; |
309
|
|
|
} |
310
|
|
|
|
311
|
|
|
/** |
312
|
|
|
* Loop DOM element recursively and parse out all dynamic vue attributes which are basically JS expressions |
313
|
|
|
* |
314
|
|
|
* @param array $attributePrefixes List of attribute prefixes we parse as JS (may contain translations) |
315
|
|
|
* @param DOMNode $dom |
316
|
|
|
* @param array $expressionByLine [lineNumber => [jsExpression, ..], ..] |
317
|
|
|
* @return array [lineNumber => [jsExpression, ..], ..] |
318
|
|
|
*/ |
319
|
|
|
private static function getVueAttributeExpressions( |
320
|
|
|
array $attributePrefixes, |
321
|
|
|
DOMNode $dom, |
322
|
|
|
array &$expressionByLine = [] |
323
|
|
|
) { |
324
|
|
|
$children = $dom->childNodes; |
325
|
|
|
|
326
|
|
|
for ($i = 0; $i < $children->length; $i++) { |
327
|
|
|
$node = $children->item($i); |
328
|
|
|
|
329
|
|
|
if (!($node instanceof DOMElement)) { |
330
|
|
|
continue; |
331
|
|
|
} |
332
|
|
|
$attrList = $node->attributes; |
333
|
|
|
|
334
|
|
|
for ($j = 0; $j < $attrList->length; $j++) { |
|
|
|
|
335
|
|
|
/** @var DOMAttr $domAttr */ |
336
|
|
|
$domAttr = $attrList->item($j); |
337
|
|
|
|
338
|
|
|
// Check if this is a dynamic vue attribute |
339
|
|
|
if (self::isAttributeMatching($domAttr->name, $attributePrefixes)) { |
340
|
|
|
$line = $domAttr->getLineNo(); |
341
|
|
|
$expressionByLine += [$line => []]; |
342
|
|
|
$expressionByLine[$line][] = $domAttr->value; |
343
|
|
|
} |
344
|
|
|
} |
345
|
|
|
|
346
|
|
|
if ($node->hasChildNodes()) { |
347
|
|
|
$expressionByLine = self::getVueAttributeExpressions($attributePrefixes, $node, $expressionByLine); |
348
|
|
|
} |
349
|
|
|
} |
350
|
|
|
|
351
|
|
|
return $expressionByLine; |
352
|
|
|
} |
353
|
|
|
|
354
|
|
|
/** |
355
|
|
|
* Check if this attribute name should be parsed for translations |
356
|
|
|
* |
357
|
|
|
* @param string $attributeName |
358
|
|
|
* @param string[] $attributePrefixes |
359
|
|
|
* @return bool |
360
|
|
|
*/ |
361
|
|
|
private static function isAttributeMatching($attributeName, $attributePrefixes) |
362
|
|
|
{ |
363
|
|
|
foreach ($attributePrefixes as $prefix) { |
364
|
|
|
if (strpos($attributeName, $prefix) === 0) { |
365
|
|
|
return true; |
366
|
|
|
} |
367
|
|
|
} |
368
|
|
|
return false; |
369
|
|
|
} |
370
|
|
|
|
371
|
|
|
/** |
372
|
|
|
* Extract JS expressions from within template elements (excluding attributes) |
373
|
|
|
* For example: <span :title="skip attributes"> {{__("extract element content")}} </span> |
374
|
|
|
* |
375
|
|
|
* @param DOMNode $dom |
376
|
|
|
* @return string JS code |
377
|
|
|
*/ |
378
|
|
|
private static function getTemplateFakeJs(DOMNode $dom) |
379
|
|
|
{ |
380
|
|
|
$fakeJs = ''; |
381
|
|
|
$lines = explode("\n", $dom->textContent); |
382
|
|
|
|
383
|
|
|
// Build a fake JS file from template by extracting JS expressions within each template line |
384
|
|
|
foreach ($lines as $line) { |
385
|
|
|
$expressionMatched = self::parseOneTemplateLine($line); |
386
|
|
|
|
387
|
|
|
$fakeJs .= implode("; ", $expressionMatched) . "\n"; |
388
|
|
|
} |
389
|
|
|
|
390
|
|
|
return $fakeJs; |
391
|
|
|
} |
392
|
|
|
|
393
|
|
|
/** |
394
|
|
|
* Match JS expressions in a template line |
395
|
|
|
* |
396
|
|
|
* @param string $line |
397
|
|
|
* @return string[] |
398
|
|
|
*/ |
399
|
|
|
private static function parseOneTemplateLine($line) |
400
|
|
|
{ |
401
|
|
|
$line = trim($line); |
402
|
|
|
|
403
|
|
|
if (!$line) { |
404
|
|
|
return []; |
405
|
|
|
} |
406
|
|
|
|
407
|
|
|
$regex = '#\{\{(.*?)\}\}#'; |
408
|
|
|
|
409
|
|
|
preg_match_all($regex, $line, $matches); |
410
|
|
|
|
411
|
|
|
$matched = array_map(function ($v) { |
412
|
|
|
return trim($v, '\'"{}'); |
413
|
|
|
}, $matches[1]); |
414
|
|
|
|
415
|
|
|
return $matched; |
416
|
|
|
} |
417
|
|
|
} |
418
|
|
|
|
Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.
You can also find more detailed suggestions in the “Code” section of your repository.