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

VueJs::getTemplateTranslations()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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