Completed
Pull Request — master (#178)
by Mārtiņš
01:25
created

VueJs::getTemplateFakeJs()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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