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

VueJs::getTemplateAttributeFakeJs()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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