Completed
Push — master ( 1ad5db...3c5b13 )
by Oscar
01:36
created

VueJs::getVueAttributeExpressions()   B

Complexity

Conditions 6
Paths 4

Size

Total Lines 35
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 19
nc 4
nop 3
dl 0
loc 35
rs 8.439
c 0
b 0
f 0
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
        ];
31
32
        // Ok, this is the weirdest hack, but let me explain:
33
        // On Linux (Mac is fine), when converting HTML to DOM, new lines get trimmed after the first tag.
34
        // So if there are new lines between <template> and next element, they are lost
35
        // So we insert a "." which is a text node, and it will prevent that newlines are stripped between elements.
36
        // Same thing happens between template and script tag.
37
        $string = str_replace('<template>', '<template>.', $string);
38
        $string = str_replace('</template>', '</template>.', $string);
39
40
        // Normalize newlines
41
        $string = str_replace(["\r\n", "\n\r", "\r"], "\n", $string);
42
43
        // VueJS files are valid HTML files, we will operate with the DOM here
44
        $dom = self::convertHtmlToDom($string);
45
46
        // Parse the script part as a regular JS code
47
        $script = $dom->getElementsByTagName('script')->item(0);
48
        if ($script) {
49
            self::getScriptTranslationsFromString(
50
                $script->textContent,
51
                $translations,
52
                $options,
53
                $script->getLineNo() - 1
54
            );
55
        }
56
57
        // Template part is parsed separately, all variables will be extracted
58
        // and handled as a regular JS code
59
        $template = $dom->getElementsByTagName('template')->item(0);
60
        if ($template) {
61
            self::getTemplateTranslations(
62
                $template,
63
                $translations,
64
                $options,
65
                $template->getLineNo() - 1
66
            );
67
        }
68
    }
69
70
    /**
71
     * @param string $html
72
     * @return DOMDocument
73
     */
74
    private static function convertHtmlToDom($html)
75
    {
76
        $dom = new DOMDocument;
77
78
        libxml_use_internal_errors(true);
79
        $dom->loadHTML($html);
80
81
        libxml_clear_errors();
82
83
        return $dom;
84
    }
85
86
    /**
87
     * Extract translations from script part
88
     *
89
     * @param string $scriptContents Only script tag contents, not the whole template
90
     * @param Translations $translations
91
     * @param array $options
92
     * @param int $lineOffset Number of lines the script is offset in the vue template file
93
     * @throws \Exception
94
     */
95
    private static function getScriptTranslationsFromString(
96
        $scriptContents,
97
        Translations $translations,
98
        array $options = [],
99
        $lineOffset = 0
100
    ) {
101
        $functions = new JsFunctionsScanner($scriptContents);
102
        $options['lineOffset'] = $lineOffset;
103
        $functions->saveGettextFunctions($translations, $options);
104
    }
105
106
    /**
107
     * Parse template to extract all translations (element content and dynamic element attributes)
108
     *
109
     * @param DOMElement $dom
110
     * @param Translations $translations
111
     * @param array $options
112
     * @param int $lineOffset Line number where the template part starts in the vue file
113
     * @throws \Exception
114
     */
115
    private static function getTemplateTranslations(
116
        DOMElement $dom,
117
        Translations $translations,
118
        array $options,
119
        $lineOffset = 0
120
    ) {
121
        // Build a JS string from all template attribute expressions
122
        $fakeAttributeJs = self::getTemplateAttributeFakeJs($options, $dom);
123
124
        // 1 line offset is necessary because parent template element was ignored when converting to DOM
125
        self::getScriptTranslationsFromString($fakeAttributeJs, $translations, $options, $lineOffset);
126
127
        // Build a JS string from template element content expressions
128
        $fakeTemplateJs = self::getTemplateFakeJs($dom);
129
        self::getScriptTranslationsFromString($fakeTemplateJs, $translations, $options, $lineOffset);
130
    }
131
132
    /**
133
     * Extract JS expressions from element attribute bindings (excluding text within elements)
134
     * For example: <span :title="__('extract this')"> skip element content </span>
135
     *
136
     * @param array $options
137
     * @param DOMElement $dom
138
     * @return string JS code
139
     */
140
    private static function getTemplateAttributeFakeJs(array $options, DOMElement $dom)
141
    {
142
        $expressionsByLine = self::getVueAttributeExpressions($options['attributePrefixes'], $dom);
143
144
        $maxLines = max(array_keys($expressionsByLine));
145
        $fakeJs = '';
146
147
        for ($line = 1; $line <= $maxLines; $line++) {
148
            if (isset($expressionsByLine[$line])) {
149
                $fakeJs .= implode("; ", $expressionsByLine[$line]);
150
            }
151
            $fakeJs .= "\n";
152
        }
153
154
        return $fakeJs;
155
    }
156
157
    /**
158
     * Loop DOM element recursively and parse out all dynamic vue attributes which are basically JS expressions
159
     *
160
     * @param array $attributePrefixes List of attribute prefixes we parse as JS (may contain translations)
161
     * @param DOMElement $dom
162
     * @param array $expressionByLine [lineNumber => [jsExpression, ..], ..]
163
     * @return array [lineNumber => [jsExpression, ..], ..]
164
     */
165
    private static function getVueAttributeExpressions(
166
        array $attributePrefixes,
167
        DOMElement $dom,
168
        array &$expressionByLine = []
169
    ) {
170
        $children = $dom->childNodes;
171
172
        for ($i = 0; $i < $children->length; $i++) {
173
            $node = $children->item($i);
174
175
            if (!($node instanceof DOMElement)) {
176
                continue;
177
            }
178
179
            $attrList = $node->attributes;
180
181
            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...
182
                /** @var DOMAttr $domAttr */
183
                $domAttr = $attrList->item($j);
184
185
                // Check if this is a dynamic vue attribute
186
                if (self::isAttributeMatching($domAttr->name, $attributePrefixes)) {
187
                    $line = $domAttr->getLineNo();
188
                    $expressionByLine += [$line => []];
189
                    $expressionByLine[$line][] = $domAttr->value;
190
                }
191
            }
192
193
            if ($node->hasChildNodes()) {
194
                $expressionByLine = self::getVueAttributeExpressions($attributePrefixes, $node, $expressionByLine);
195
            }
196
        }
197
198
        return $expressionByLine;
199
    }
200
201
    /**
202
     * Check if this attribute name should be parsed for translations
203
     *
204
     * @param string $attributeName
205
     * @param string[] $attributePrefixes
206
     * @return bool
207
     */
208
    private static function isAttributeMatching($attributeName, $attributePrefixes)
209
    {
210
        foreach ($attributePrefixes as $prefix) {
211
            if (strpos($attributeName, $prefix) === 0) {
212
                return true;
213
            }
214
        }
215
        return false;
216
    }
217
218
    /**
219
     * Extract JS expressions from within template elements (excluding attributes)
220
     * For example: <span :title="skip attributes"> {{__("extract element content")}} </span>
221
     *
222
     * @param DOMElement $dom
223
     * @return string JS code
224
     */
225
    private static function getTemplateFakeJs(DOMElement $dom)
226
    {
227
        $fakeJs = '';
228
        $lines = explode("\n", $dom->textContent);
229
230
        // Build a fake JS file from template by extracting JS expressions within each template line
231
        foreach ($lines as $line) {
232
            $expressionMatched = self::parseOneTemplateLine($line);
233
234
            $fakeJs .= implode("; ", $expressionMatched) . "\n";
235
        }
236
237
        return $fakeJs;
238
    }
239
240
    /**
241
     * Match JS expressions in a template line
242
     *
243
     * @param string $line
244
     * @return string[]
245
     */
246
    private static function parseOneTemplateLine($line)
247
    {
248
        $line = trim($line);
249
250
        if (!$line) {
251
            return [];
252
        }
253
254
        $regex = '#\{\{(.*?)\}\}#';
255
256
        preg_match_all($regex, $line, $matches);
257
258
        $matched = array_map(function ($v) {
259
            return trim($v, '\'"{}');
260
        }, $matches[1]);
261
262
        return $matched;
263
    }
264
}
265