Completed
Push — master ( dc92f6...85aaa5 )
by Oscar
01:28
created

VueJs::fromString()   B

Complexity

Conditions 3
Paths 4

Size

Total Lines 71

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
nc 4
nop 3
dl 0
loc 71
rs 8.6327
c 0
b 0
f 0

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace Gettext\Extractors;
4
5
use DOMAttr;
6
use DOMDocument;
7
use DOMElement;
8
use Gettext\Translations;
9
use Gettext\Utils\JsFunctionsScanner;
10
11
/**
12
 * Class to get gettext strings from VueJS template files.
13
 */
14
class VueJs extends JsCode implements ExtractorInterface
15
{
16
    /**
17
     * @inheritdoc
18
     * @throws \Exception
19
     */
20
    public static function fromString($string, Translations $translations, array $options = [])
21
    {
22
        $options += self::$options;
23
        $options += [
24
            // HTML attribute prefixes we parse as JS which could contain translations (are JS expressions)
25
            'attributePrefixes' => [
26
                ':',
27
                'v-bind:',
28
                'v-on:',
29
            ],
30
            // HTML Tags to parse
31
            'tagNames' => [
32
                'translate',
33
            ],
34
            // HTML tags to parse when attribute exists
35
            'tagAttributes' => [
36
                'v-translate'
37
            ],
38
            // Comments
39
            'commentAttributes' => [
40
                'translate-comment'
41
            ],
42
            'contextAttributes' => [
43
                'translate-context'
44
            ],
45
            // Attribute with plural content
46
            'pluralAttributes' => [
47
                'translate-plural'
48
            ]
49
        ];
50
51
        // Ok, this is the weirdest hack, but let me explain:
52
        // On Linux (Mac is fine), when converting HTML to DOM, new lines get trimmed after the first tag.
53
        // So if there are new lines between <template> and next element, they are lost
54
        // So we insert a "." which is a text node, and it will prevent that newlines are stripped between elements.
55
        // Same thing happens between template and script tag.
56
        $string = str_replace('<template>', '<template>.', $string);
57
        $string = str_replace('</template>', '</template>.', $string);
58
59
        // Normalize newlines
60
        $string = str_replace(["\r\n", "\n\r", "\r"], "\n", $string);
61
62
        // VueJS files are valid HTML files, we will operate with the DOM here
63
        $dom = self::convertHtmlToDom($string);
64
65
        $script = self::extractScriptTag($string);
66
67
        // Parse the script part as a regular JS code
68
        if ($script) {
69
            $scriptLineNumber = $dom->getElementsByTagName('script')->item(0)->getLineNo();
70
            self::getScriptTranslationsFromString(
71
                $script,
0 ignored issues
show
Bug introduced by
It seems like $script defined by self::extractScriptTag($string) on line 65 can also be of type boolean; however, Gettext\Extractors\VueJs...ranslationsFromString() does only seem to accept string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
72
                $translations,
73
                $options,
74
                $scriptLineNumber - 1
75
            );
76
        }
77
78
        // Template part is parsed separately, all variables will be extracted
79
        // and handled as a regular JS code
80
        $template = $dom->getElementsByTagName('template')->item(0);
81
        if ($template) {
82
            self::getTemplateTranslations(
83
                $template,
84
                $translations,
85
                $options,
86
                $template->getLineNo() - 1
87
            );
88
        }
89
90
    }
91
92
    /**
93
     * Extracts script tag contents using regex instead of DOM operations.
94
     * If we parse using DOM, some contents may change, for example, tags within strings will be stripped
95
     *
96
     * @param $string
97
     * @return bool|string
98
     */
99
    private static function extractScriptTag($string)
100
    {
101
        if (preg_match('#<\s*?script\b[^>]*>(.*?)</script\b[^>]*>#s', $string, $matches)) {
102
            return $matches[1];
103
        }
104
105
        return '';
106
    }
107
108
    /**
109
     * @param string $html
110
     * @return DOMDocument
111
     */
112
    private static function convertHtmlToDom($html)
113
    {
114
        $dom = new DOMDocument;
115
116
        libxml_use_internal_errors(true);
117
        $dom->loadHTML($html);
118
119
        libxml_clear_errors();
120
121
        return $dom;
122
    }
123
124
    /**
125
     * Extract translations from script part
126
     *
127
     * @param string $scriptContents Only script tag contents, not the whole template
128
     * @param Translations $translations
129
     * @param array $options
130
     * @param int $lineOffset Number of lines the script is offset in the vue template file
131
     * @throws \Exception
132
     */
133
    private static function getScriptTranslationsFromString(
134
        $scriptContents,
135
        Translations $translations,
136
        array $options = [],
137
        $lineOffset = 0
138
    ) {
139
        $functions = new JsFunctionsScanner($scriptContents);
140
        $options['lineOffset'] = $lineOffset;
141
        $functions->saveGettextFunctions($translations, $options);
142
    }
143
144
    /**
145
     * Parse template to extract all translations (element content and dynamic element attributes)
146
     *
147
     * @param DOMElement $dom
148
     * @param Translations $translations
149
     * @param array $options
150
     * @param int $lineOffset Line number where the template part starts in the vue file
151
     * @throws \Exception
152
     */
153
    private static function getTemplateTranslations(
154
        DOMElement $dom,
155
        Translations $translations,
156
        array $options,
157
        $lineOffset = 0
158
    ) {
159
        // Build a JS string from all template attribute expressions
160
        $fakeAttributeJs = self::getTemplateAttributeFakeJs($options, $dom);
161
162
        // 1 line offset is necessary because parent template element was ignored when converting to DOM
163
        self::getScriptTranslationsFromString($fakeAttributeJs, $translations, $options, $lineOffset);
164
165
        // Build a JS string from template element content expressions
166
        $fakeTemplateJs = self::getTemplateFakeJs($dom);
167
        self::getScriptTranslationsFromString($fakeTemplateJs, $translations, $options, $lineOffset);
168
169
        self::getTagTranslations($options, $dom, $translations);
170
    }
171
172
    /**
173
     * @param array $options
174
     * @param DOMElement $dom
175
     * @param Translations $translations
176
     */
177
    private static function getTagTranslations(array $options, DOMElement $dom, Translations $translations)
178
    {
179
        $children = $dom->childNodes;
180
        for ($i = 0; $i < $children->length; $i++) {
181
            $node = $children->item($i);
182
183
            if (!($node instanceof DOMElement)) {
184
                continue;
185
            }
186
            $translatable = false;
187
            if (\in_array($node->tagName, $options['tagNames'])) {
188
                $translatable = true;
189
            }
190
            $attrList = $node->attributes;
191
            $context = null;
192
            $plural = "";
193
            $comment = null;
194
            for ($j = 0; $j < $attrList->length; $j++) {
0 ignored issues
show
Bug introduced by
The property length does not seem to exist in DOMNamedNodeMap.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
195
                /** @var DOMAttr $domAttr */
196
                $domAttr = $attrList->item($j);
197
                // Check if this is a dynamic vue attribute
198
                if (\in_array($domAttr->name, $options['tagAttributes'])) {
199
                    $translatable = true;
200
                }
201
                if (\in_array($domAttr->name, $options['contextAttributes'])) {
202
                    $context = $domAttr->value;
203
                }
204
                if (\in_array($domAttr->name, $options['pluralAttributes'])) {
205
                    $plural = $domAttr->value;
206
                }
207
                if (\in_array($domAttr->name, $options['commentAttributes'])) {
208
                    $comment = $domAttr->value;
209
                }
210
            }
211
            if ($translatable) {
212
                $translation = $translations->insert($context, \trim($node->textContent), $plural);
213
                $translation->addReference($options['file'], $node->getLineNo());
214
                if ($comment) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $comment of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
215
                    $translation->addExtractedComment($comment);
216
                }
217
            }
218
            if ($node->hasChildNodes()) {
219
                self::getTagTranslations($options, $node, $translations);
220
            }
221
        }
222
    }
223
224
    /**
225
     * Extract JS expressions from element attribute bindings (excluding text within elements)
226
     * For example: <span :title="__('extract this')"> skip element content </span>
227
     *
228
     * @param array $options
229
     * @param DOMElement $dom
230
     * @return string JS code
231
     */
232
    private static function getTemplateAttributeFakeJs(array $options, DOMElement $dom)
233
    {
234
        $expressionsByLine = self::getVueAttributeExpressions($options['attributePrefixes'], $dom);
235
236
        if (empty($expressionsByLine)) {
237
            return '';
238
        }
239
240
        $maxLines = max(array_keys($expressionsByLine));
241
        $fakeJs = '';
242
243
        for ($line = 1; $line <= $maxLines; $line++) {
244
            if (isset($expressionsByLine[$line])) {
245
                $fakeJs .= implode("; ", $expressionsByLine[$line]);
246
            }
247
            $fakeJs .= "\n";
248
        }
249
250
        return $fakeJs;
251
    }
252
253
    /**
254
     * Loop DOM element recursively and parse out all dynamic vue attributes which are basically JS expressions
255
     *
256
     * @param array $attributePrefixes List of attribute prefixes we parse as JS (may contain translations)
257
     * @param DOMElement $dom
258
     * @param array $expressionByLine [lineNumber => [jsExpression, ..], ..]
259
     * @return array [lineNumber => [jsExpression, ..], ..]
260
     */
261
    private static function getVueAttributeExpressions(
262
        array $attributePrefixes,
263
        DOMElement $dom,
264
        array &$expressionByLine = []
265
    ) {
266
        $children = $dom->childNodes;
267
268
        for ($i = 0; $i < $children->length; $i++) {
269
            $node = $children->item($i);
270
271
            if (!($node instanceof DOMElement)) {
272
                continue;
273
            }
274
            $attrList = $node->attributes;
275
276
            for ($j = 0; $j < $attrList->length; $j++) {
0 ignored issues
show
Bug introduced by
The property length does not seem to exist in DOMNamedNodeMap.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
277
                /** @var DOMAttr $domAttr */
278
                $domAttr = $attrList->item($j);
279
280
                // Check if this is a dynamic vue attribute
281
                if (self::isAttributeMatching($domAttr->name, $attributePrefixes)) {
282
                    $line = $domAttr->getLineNo();
283
                    $expressionByLine += [$line => []];
284
                    $expressionByLine[$line][] = $domAttr->value;
285
                }
286
            }
287
288
            if ($node->hasChildNodes()) {
289
                $expressionByLine = self::getVueAttributeExpressions($attributePrefixes, $node, $expressionByLine);
290
            }
291
        }
292
293
        return $expressionByLine;
294
    }
295
296
    /**
297
     * Check if this attribute name should be parsed for translations
298
     *
299
     * @param string $attributeName
300
     * @param string[] $attributePrefixes
301
     * @return bool
302
     */
303
    private static function isAttributeMatching($attributeName, $attributePrefixes)
304
    {
305
        foreach ($attributePrefixes as $prefix) {
306
            if (strpos($attributeName, $prefix) === 0) {
307
                return true;
308
            }
309
        }
310
        return false;
311
    }
312
313
    /**
314
     * Extract JS expressions from within template elements (excluding attributes)
315
     * For example: <span :title="skip attributes"> {{__("extract element content")}} </span>
316
     *
317
     * @param DOMElement $dom
318
     * @return string JS code
319
     */
320
    private static function getTemplateFakeJs(DOMElement $dom)
321
    {
322
        $fakeJs = '';
323
        $lines = explode("\n", $dom->textContent);
324
325
        // Build a fake JS file from template by extracting JS expressions within each template line
326
        foreach ($lines as $line) {
327
            $expressionMatched = self::parseOneTemplateLine($line);
328
329
            $fakeJs .= implode("; ", $expressionMatched) . "\n";
330
        }
331
332
        return $fakeJs;
333
    }
334
335
    /**
336
     * Match JS expressions in a template line
337
     *
338
     * @param string $line
339
     * @return string[]
340
     */
341
    private static function parseOneTemplateLine($line)
342
    {
343
        $line = trim($line);
344
345
        if (!$line) {
346
            return [];
347
        }
348
349
        $regex = '#\{\{(.*?)\}\}#';
350
351
        preg_match_all($regex, $line, $matches);
352
353
        $matched = array_map(function ($v) {
354
            return trim($v, '\'"{}');
355
        }, $matches[1]);
356
357
        return $matched;
358
    }
359
}
360