@gitlab/eslint-plugin 18.3.0 → 18.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/lib/rules/require-i18n-strings.js +3 -191
- package/lib/utils/string-utils.js +194 -0
- package/package.json +1 -1
- package/scripts/benchmarking/README.md +238 -0
- package/scripts/benchmarking/checkAllStrings.js +52 -0
- package/scripts/benchmarking/getAllStrings.js +77 -0
- package/scripts/benchmarking/package.json +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## [18.3.1](https://gitlab.com/gitlab-org/frontend/eslint-plugin/compare/v18.3.0...v18.3.1) (2023-04-11)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Performance Improvements
|
|
5
|
+
|
|
6
|
+
* isCssSelector performance ([4fb2697](https://gitlab.com/gitlab-org/frontend/eslint-plugin/commit/4fb2697fe97f15fa7ebb4c2835a99e1c39fb118a))
|
|
7
|
+
* isKebabCase selector ([fba327b](https://gitlab.com/gitlab-org/frontend/eslint-plugin/commit/fba327b793bdebe50dc0983dc34fc68e4f4f51fa))
|
|
8
|
+
|
|
1
9
|
# [18.3.0](https://gitlab.com/gitlab-org/frontend/eslint-plugin/compare/v18.2.1...v18.3.0) (2023-03-23)
|
|
2
10
|
|
|
3
11
|
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// Requirements
|
|
9
9
|
// ------------------------------------------------------------------------------
|
|
10
10
|
const { isTemplateLiteral, getTemplateLiteralString } = require('../utils/rule-utils');
|
|
11
|
+
const { isConstantVariableName, isNonLocalizedStringValue } = require('../utils/string-utils');
|
|
11
12
|
|
|
12
13
|
//------------------------------------------------------------------------------
|
|
13
14
|
// Rule Definition
|
|
@@ -52,21 +53,6 @@ module.exports = {
|
|
|
52
53
|
'forceFallback',
|
|
53
54
|
]);
|
|
54
55
|
|
|
55
|
-
const cssProperties = new Set(['width', 'height']);
|
|
56
|
-
|
|
57
|
-
const htmlElements = new Set([
|
|
58
|
-
'thead',
|
|
59
|
-
'tr',
|
|
60
|
-
'td',
|
|
61
|
-
'th',
|
|
62
|
-
'sup',
|
|
63
|
-
'sub',
|
|
64
|
-
'kbd',
|
|
65
|
-
'q',
|
|
66
|
-
'samp',
|
|
67
|
-
'var',
|
|
68
|
-
]);
|
|
69
|
-
|
|
70
56
|
const objectMethods = new Set([
|
|
71
57
|
'indexOf',
|
|
72
58
|
'includes',
|
|
@@ -101,180 +87,6 @@ module.exports = {
|
|
|
101
87
|
|
|
102
88
|
const dateMethods = new Set(['dateFormat']);
|
|
103
89
|
|
|
104
|
-
function isFileName(value) {
|
|
105
|
-
return /^(\w+\.?)*\.\w{2,}$/.test(value);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function isPath(value) {
|
|
109
|
-
return /^([\w-_]*|\.{1,2})[\/\~]+[\w-_\/]*/.test(value);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function isUrl(value) {
|
|
113
|
-
return (
|
|
114
|
-
/[a-zA-Z0-9]*(:\/\/)+[a-zA-Z0-9\.]*/.test(value) || /(http){1}[s:\/a-zA-Z0-9.]*/.test(value)
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function isMailto(str) {
|
|
119
|
-
return (str || '').startsWith('mailto:');
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function isPlaceholder(value) {
|
|
123
|
-
return /^{?{\w+}}?$/.test(value);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function isListOfHtmlElements(value) {
|
|
127
|
-
const splitChar = value.includes(',') ? ', ' : ' ';
|
|
128
|
-
return value.split(splitChar).every((string) => {
|
|
129
|
-
return htmlElements.has(string);
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function isListOfCssProperties(value) {
|
|
134
|
-
const splitChar = value.includes(',') ? ', ' : ' ';
|
|
135
|
-
return value.split(splitChar).every((string) => {
|
|
136
|
-
return cssProperties.has(string);
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function isCssSelector(value) {
|
|
141
|
-
return (
|
|
142
|
-
/^,?\s?[\.\#\*\@]\w+.*/.test(value) ||
|
|
143
|
-
/^[a-zA-Z]+[a-zA-Z-]*[\.\#\*]+[a-zA-Z]+[a-zA-Z-]*/.test(value) ||
|
|
144
|
-
/([a-zA-Z][a-zA-Z-]+[a-zA-Z])+\.\w+/.test(value) ||
|
|
145
|
-
/([a-zA-Z]+[a-zA-Z-]*|\*)\[.*\]/.test(value) ||
|
|
146
|
-
/^[a-zA-Z]+[a-zA-Z-]*[\#\*]$/.test(value) ||
|
|
147
|
-
/^(?:[0-9]+[a-z]|[a-z]+[0-9])[a-z0-9]*\s[a-z0-9][a-z0-9]+-[a-z0-9-]*$/.test(value) ||
|
|
148
|
-
isListOfCssProperties(value) ||
|
|
149
|
-
isListOfHtmlElements(value)
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function isCssValue(value) {
|
|
154
|
-
return /^\d+(%|rem|em|vh|vw|px)$/.test(value) || /^\(\w+:\s-[a-z0-9-]+\)/.test(value);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function isMethodCall(value) {
|
|
158
|
-
return /^\w+\(.*\)$/.test(value);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function isKebabCase(value) {
|
|
162
|
-
return (
|
|
163
|
-
/^([a-zA-Z0-9]+-[a-zA-Z0-9-]*[\s,]*)+$/.test(value) ||
|
|
164
|
-
/^([a-z0-9]+-[a-z0-9-]*)+(\s[a-z0-9-]*)*/.test(value) ||
|
|
165
|
-
/(\s[a-z0-9-]*)*([a-z0-9]+-[a-z0-9-]*)+$/.test(value)
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function isScreamingKebabCase(value) {
|
|
170
|
-
return /^([A-Z]+-[A-Z-]*[A-Z]*[\s,]*)+$/.test(value);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function containsCamelCase(value) {
|
|
174
|
-
return /[a-z]+[A-Z]{1}/.test(value);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function containsSnakeCase(value) {
|
|
178
|
-
return /([a-z]+_[a-z_]*[\s,]*)+/.test(value);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function isScreamingSnakeCase(value) {
|
|
182
|
-
return /^([A-Z]+_[A-Z_]+[A-Z][\s,]*)+$/.test(value);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function isIndex(value) {
|
|
186
|
-
return /^[\w-]+:[\w-]+$/.test(value);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function containsAssignment(value) {
|
|
190
|
-
return value.match(/([a-z]*=['|"])/) || value.match(/(\[[a-z]*=)/);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function isSpecial(value) {
|
|
194
|
-
return /^[\:\%\_\&\#\?\[\$].*[^.]/.test(value) || /.*[\*\+]+$/.test(value);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function isVariableName(value) {
|
|
198
|
-
return (
|
|
199
|
-
containsCamelCase(value) ||
|
|
200
|
-
containsSnakeCase(value) ||
|
|
201
|
-
isKebabCase(value) ||
|
|
202
|
-
isScreamingKebabCase(value) ||
|
|
203
|
-
isScreamingSnakeCase(value)
|
|
204
|
-
);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function isHtml(value) {
|
|
208
|
-
return /<("[^"]*"|'[^']*'|[^'">])*>/.test(value);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function isColonSeperated(value) {
|
|
212
|
-
return /(([a-zA-Z])*:)+([a-zA-Z])+/.test(value);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function isNonEmptyString(value) {
|
|
216
|
-
return typeof value === 'string' && value.trim() !== '';
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function isNumber(value) {
|
|
220
|
-
return /^\d+$/.test(value);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function isLowerCaseWord(value) {
|
|
224
|
-
return /^[a-z0-9]+$/.test(value);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function isUpperCaseWord(value) {
|
|
228
|
-
return /^[A-Z]+$/.test(value);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function isConstantVariableName(value) {
|
|
232
|
-
return isUpperCaseWord(value) || isScreamingSnakeCase(value);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function hasOneWord(value) {
|
|
236
|
-
return /[a-zA-Z]{1,}\s|[a-zA-Z]{2,}/.test(value);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function isDateFormat(value) {
|
|
240
|
-
return /^[M|D|Y|m|d|y\s|,]+$/.test(value);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function isEmptyVariableAssignment(value) {
|
|
244
|
-
return /\w+=["|']{2}/.test(value);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function isKeyboardShortcut(value) {
|
|
248
|
-
return /([a-z0-9]*\+{1})+([a-z0-9]+)/.test(value);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function isNonLocalizedStringValue(value) {
|
|
252
|
-
return (
|
|
253
|
-
isNonEmptyString(value) &&
|
|
254
|
-
hasOneWord(value) &&
|
|
255
|
-
!isLowerCaseWord(value) &&
|
|
256
|
-
!isUpperCaseWord(value) &&
|
|
257
|
-
!isNumber(value) &&
|
|
258
|
-
!isPath(value) &&
|
|
259
|
-
!isUrl(value) &&
|
|
260
|
-
!isMailto(value) &&
|
|
261
|
-
!isHtml(value) &&
|
|
262
|
-
!isCssSelector(value) &&
|
|
263
|
-
!isCssValue(value) &&
|
|
264
|
-
!isMethodCall(value) &&
|
|
265
|
-
!isVariableName(value) &&
|
|
266
|
-
!isSpecial(value) &&
|
|
267
|
-
!isFileName(value) &&
|
|
268
|
-
!isPlaceholder(value) &&
|
|
269
|
-
!isIndex(value) &&
|
|
270
|
-
!isDateFormat(value) &&
|
|
271
|
-
!isEmptyVariableAssignment(value) &&
|
|
272
|
-
!isKeyboardShortcut(value) &&
|
|
273
|
-
!isColonSeperated(value) &&
|
|
274
|
-
!containsAssignment(value)
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
90
|
function isStringLiteral(node) {
|
|
279
91
|
return node.type === 'Literal';
|
|
280
92
|
}
|
|
@@ -317,7 +129,7 @@ module.exports = {
|
|
|
317
129
|
});
|
|
318
130
|
}
|
|
319
131
|
|
|
320
|
-
function
|
|
132
|
+
function isTrueConstant(node, declaration) {
|
|
321
133
|
return node.kind === 'const' && isConstantVariableName(declaration.id.name);
|
|
322
134
|
}
|
|
323
135
|
|
|
@@ -387,7 +199,7 @@ module.exports = {
|
|
|
387
199
|
(node.parent.left && isHtmlProperty(node.parent.left)) ||
|
|
388
200
|
(node.parent.id && isHtmlProperty(node.parent.id)) ||
|
|
389
201
|
(node.parent.key && isHtmlProperty(node.parent.key)) ||
|
|
390
|
-
|
|
202
|
+
isTrueConstant(node.parent.parent, node.parent) ||
|
|
391
203
|
isKeyList(node.parent.parent, node.parent) ||
|
|
392
204
|
isMemberExpressionIdentifier(node.parent)
|
|
393
205
|
);
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
const cssProperties = new Set(['width', 'height']);
|
|
2
|
+
const htmlElements = new Set(['thead', 'tr', 'td', 'th', 'sup', 'sub', 'kbd', 'q', 'samp', 'var']);
|
|
3
|
+
|
|
4
|
+
function isFileName(value) {
|
|
5
|
+
return /^(\w+\.?)*\.\w{2,}$/.test(value);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function isPath(value) {
|
|
9
|
+
return /^([\w-_]*|\.{1,2})[\/\~]+[\w-_\/]*/.test(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isUrl(value) {
|
|
13
|
+
return (
|
|
14
|
+
/[a-zA-Z0-9]*(:\/\/)+[a-zA-Z0-9\.]*/.test(value) || /(http){1}[s:\/a-zA-Z0-9.]*/.test(value)
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isMailto(str) {
|
|
19
|
+
return (str || '').startsWith('mailto:');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isPlaceholder(value) {
|
|
23
|
+
return /^{?{\w+}}?$/.test(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isListOfHtmlElements(value) {
|
|
27
|
+
const splitChar = value.includes(',') ? ', ' : ' ';
|
|
28
|
+
return value.split(splitChar).every((string) => {
|
|
29
|
+
return htmlElements.has(string);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isListOfCssProperties(value) {
|
|
34
|
+
const splitChar = value.includes(',') ? ', ' : ' ';
|
|
35
|
+
return value.split(splitChar).every((string) => {
|
|
36
|
+
return cssProperties.has(string);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isCssSelector(value) {
|
|
41
|
+
return (
|
|
42
|
+
/^,?\s?[\.\#\*\@]\w+.*/.test(value) ||
|
|
43
|
+
/^[a-zA-Z]+[a-zA-Z-]*[\.\#\*]+[a-zA-Z]+[a-zA-Z-]*/.test(value) ||
|
|
44
|
+
/[a-zA-Z][a-zA-Z-]+[a-zA-Z]\.\w/.test(value) ||
|
|
45
|
+
/([a-zA-Z]+[a-zA-Z-]*|\*)\[.*\]/.test(value) ||
|
|
46
|
+
/^[a-zA-Z]+[a-zA-Z-]*[\#\*]$/.test(value) ||
|
|
47
|
+
/^(?:[0-9]+[a-z]|[a-z]+[0-9])[a-z0-9]*\s[a-z0-9][a-z0-9]+-[a-z0-9-]*$/.test(value) ||
|
|
48
|
+
isListOfCssProperties(value) ||
|
|
49
|
+
isListOfHtmlElements(value)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isCssValue(value) {
|
|
54
|
+
return /^\d+(%|rem|em|vh|vw|px)$/.test(value) || /^\(\w+:\s-[a-z0-9-]+\)/.test(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isMethodCall(value) {
|
|
58
|
+
return /^\w+\(.*\)$/.test(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function trimAndSplit(value, separator, trimLeft = true, trimRight = true) {
|
|
62
|
+
const list = value.split(separator);
|
|
63
|
+
if (trimRight && list[list.length - 1] === '') {
|
|
64
|
+
list.pop();
|
|
65
|
+
}
|
|
66
|
+
if (trimLeft && list[0] === '') {
|
|
67
|
+
list.shift();
|
|
68
|
+
}
|
|
69
|
+
return list;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isKebabCase(value) {
|
|
73
|
+
return (
|
|
74
|
+
/^[a-z0-9]+-/.test(value) ||
|
|
75
|
+
/[a-z0-9]+-[a-z0-9-]*$/.test(value) ||
|
|
76
|
+
trimAndSplit(value, /[\s,]+/, false, true).every((item) =>
|
|
77
|
+
/^[a-zA-Z0-9]+-[a-zA-Z0-9-]*$/.test(item),
|
|
78
|
+
)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isScreamingKebabCase(value) {
|
|
83
|
+
return /^([A-Z]+-[A-Z-]*[A-Z]*[\s,]*)+$/.test(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function containsCamelCase(value) {
|
|
87
|
+
return /[a-z]+[A-Z]{1}/.test(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function containsSnakeCase(value) {
|
|
91
|
+
return /([a-z]+_[a-z_]*[\s,]*)+/.test(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isScreamingSnakeCase(value) {
|
|
95
|
+
return /^([A-Z]+_[A-Z_]+[A-Z][\s,]*)+$/.test(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isIndex(value) {
|
|
99
|
+
return /^[\w-]+:[\w-]+$/.test(value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function containsAssignment(value) {
|
|
103
|
+
return value.match(/([a-z]*=['|"])/) || value.match(/(\[[a-z]*=)/);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isSpecial(value) {
|
|
107
|
+
return /^[\:\%\_\&\#\?\[\$].*[^.]/.test(value) || /.*[\*\+]+$/.test(value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isVariableName(value) {
|
|
111
|
+
return (
|
|
112
|
+
containsCamelCase(value) ||
|
|
113
|
+
containsSnakeCase(value) ||
|
|
114
|
+
isKebabCase(value) ||
|
|
115
|
+
isScreamingKebabCase(value) ||
|
|
116
|
+
isScreamingSnakeCase(value)
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isHtml(value) {
|
|
121
|
+
return /<("[^"]*"|'[^']*'|[^'">])*>/.test(value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isColonSeperated(value) {
|
|
125
|
+
return /(([a-zA-Z])*:)+([a-zA-Z])+/.test(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isNonEmptyString(value) {
|
|
129
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isNumber(value) {
|
|
133
|
+
return /^\d+$/.test(value);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isLowerCaseWord(value) {
|
|
137
|
+
return /^[a-z0-9]+$/.test(value);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isUpperCaseWord(value) {
|
|
141
|
+
return /^[A-Z]+$/.test(value);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isConstantVariableName(value) {
|
|
145
|
+
return isUpperCaseWord(value) || isScreamingSnakeCase(value);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function hasOneWord(value) {
|
|
149
|
+
return /[a-zA-Z]{1,}\s|[a-zA-Z]{2,}/.test(value);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isDateFormat(value) {
|
|
153
|
+
return /^[M|D|Y|m|d|y\s|,]+$/.test(value);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isEmptyVariableAssignment(value) {
|
|
157
|
+
return /\w+=["|']{2}/.test(value);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isKeyboardShortcut(value) {
|
|
161
|
+
return /([a-z0-9]*\+{1})+([a-z0-9]+)/.test(value);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isNonLocalizedStringValue(value) {
|
|
165
|
+
return (
|
|
166
|
+
isNonEmptyString(value) &&
|
|
167
|
+
hasOneWord(value) &&
|
|
168
|
+
!isLowerCaseWord(value) &&
|
|
169
|
+
!isUpperCaseWord(value) &&
|
|
170
|
+
!isNumber(value) &&
|
|
171
|
+
!isPath(value) &&
|
|
172
|
+
!isUrl(value) &&
|
|
173
|
+
!isMailto(value) &&
|
|
174
|
+
!isHtml(value) &&
|
|
175
|
+
!isCssSelector(value) &&
|
|
176
|
+
!isCssValue(value) &&
|
|
177
|
+
!isMethodCall(value) &&
|
|
178
|
+
!isVariableName(value) &&
|
|
179
|
+
!isSpecial(value) &&
|
|
180
|
+
!isFileName(value) &&
|
|
181
|
+
!isPlaceholder(value) &&
|
|
182
|
+
!isIndex(value) &&
|
|
183
|
+
!isDateFormat(value) &&
|
|
184
|
+
!isEmptyVariableAssignment(value) &&
|
|
185
|
+
!isKeyboardShortcut(value) &&
|
|
186
|
+
!isColonSeperated(value) &&
|
|
187
|
+
!containsAssignment(value)
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = {
|
|
192
|
+
isNonLocalizedStringValue,
|
|
193
|
+
isConstantVariableName,
|
|
194
|
+
};
|
package/package.json
CHANGED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Benchmarking @gitlab/require-i18n-strings
|
|
2
|
+
|
|
3
|
+
At the heart of `@gitlab/require-i18n-strings` is `isNonLocalizedStringValue(value: string)` which checks whether a string matches certain criteria and would be a false positive. For example CSS class lists, or `dateformat` strings, or URLs should not be externalized.
|
|
4
|
+
|
|
5
|
+
That being said most of the checks are regex checks, and some of them very expensive. In first tests, the following are especially bad.
|
|
6
|
+
|
|
7
|
+
- `([a-zA-Z][a-zA-Z-]+[a-zA-Z])+\.\w+` inside `isCssSelector` takes ~45% of the execution time
|
|
8
|
+
- `^([a-zA-Z0-9]+-[a-zA-Z0-9-]*[\s,]*)+$` inside `isKebabCase` takes ~27% of the execution time
|
|
9
|
+
- `^(\w+\.?)*\.\w{2,}$` inside `isFileName` takes ~24% of the execution time
|
|
10
|
+
|
|
11
|
+
Why do we actually care? In context of the GitLab project the execution of: `node --cpu-prof node_modules/.bin/eslint .` takes around 7:48 minutes on my machine, of which we spend 2:15 minutes (~27%) inside of `isNonLocalizedStringValue`. As this is a heavily CPU-bound issue, we likely spend way longer on slower machines, like e.g. CI.
|
|
12
|
+
|
|
13
|
+
## Benchmarking script
|
|
14
|
+
|
|
15
|
+
Inside of this folder there are two scripts used for benchmarking. The following commands are all executed inside `scripts/benchmarking` of the eslint-plugin repo. Please run a yarn install in this folder, as we have a few different dependencies which the main plugin doesn't need.
|
|
16
|
+
|
|
17
|
+
`getAllStrings.js`: This script can be used to extract strings JS and Vue files from a code base. Just provide globs as parameters.
|
|
18
|
+
|
|
19
|
+
For example, to extract all strings from within GitLab:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
node ./getAllStrings.js "/path/to/gitlab/gitlab/{ee/,}app/**/*.{js,vue}"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The strings found are written into `allStrings.json`.
|
|
26
|
+
If you are doing iterations on `isNonLocalizedStringValue`,
|
|
27
|
+
the idea would be to just generate the lists of strings _once_,
|
|
28
|
+
in order to have a comparable baseline.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
`checkAllStrings.js`: This script has a dual purpose: Writing results of the function to a file time-stamped txt file, in order to compare the results between iterations:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
node ./checkAllStrings.js --write-results
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
In order to look at the efficiency of our function, we can get a CPU profile with:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
node --cpu-prof ./checkAllStrings.js
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The generated profile can be loaded into e.g. [speedscope]. The `left heavy` view will make it easily understandable where time is spent.
|
|
45
|
+
Thanks here for the blog series [Speeding up the JavaScript ecosystem][blog-series] which inspired me to look into this.
|
|
46
|
+
|
|
47
|
+
[speedscope]: https://www.speedscope.app
|
|
48
|
+
[blog-series]: https://marvinh.dev/blog/speeding-up-javascript-ecosystem-part-3/
|
|
49
|
+
|
|
50
|
+
## Baseline
|
|
51
|
+
|
|
52
|
+
On my machine I have generated a list of strings based on [1d13ebfbb0] in the GitLab project.
|
|
53
|
+
I found `47801` unique strings with `./getAllStrings.js`. For 13 of them the script times out.
|
|
54
|
+
|
|
55
|
+
`hyperfine --runs 3 "node ./checkAllStrings.js"` reports:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
Benchmark 1: node ./checkAllStrings.js
|
|
59
|
+
Time (mean ± σ): 42.315 s ± 0.186 s [User: 40.149 s, System: 2.791 s]
|
|
60
|
+
Range (min … max): 42.139 s … 42.510 s 3 runs
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The CPU Profile shows that `isCssSelector` is the worst offender (60% of the time spend). Let's have a look.
|
|
64
|
+
|
|
65
|
+
[1d13ebfbb0]: https://gitlab.com/gitlab-org/gitlab/-/commit/1d13ebfbb0ca339452e883a047806e7f8c167197
|
|
66
|
+
|
|
67
|
+
## `isCssSelector`
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
function isCssSelector(value) {
|
|
71
|
+
return (
|
|
72
|
+
/^,?\s?[\.\#\*\@]\w+.*/.test(value) ||
|
|
73
|
+
/^[a-zA-Z]+[a-zA-Z-]*[\.\#\*]+[a-zA-Z]+[a-zA-Z-]*/.test(value) ||
|
|
74
|
+
/([a-zA-Z][a-zA-Z-]+[a-zA-Z])+\.\w+/.test(value) ||
|
|
75
|
+
/([a-zA-Z]+[a-zA-Z-]*|\*)\[.*\]/.test(value) ||
|
|
76
|
+
/^[a-zA-Z]+[a-zA-Z-]*[\#\*]$/.test(value) ||
|
|
77
|
+
/^(?:[0-9]+[a-z]|[a-z]+[0-9])[a-z0-9]*\s[a-z0-9][a-z0-9]+-[a-z0-9-]*$/.test(value) ||
|
|
78
|
+
isListOfCssProperties(value) ||
|
|
79
|
+
isListOfHtmlElements(value)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The third RegExp in this function seems to be the offender: `/([a-zA-Z][a-zA-Z-]+[a-zA-Z])+\.\w+/`. So let us dissect it.
|
|
85
|
+
|
|
86
|
+
The `\.\w+` at the end is a literal `.` followed by _one or more_ word characters.
|
|
87
|
+
Because we actually do not care how _long_ the string of word characters at the end is, we simply can reduce it to: `\w`.
|
|
88
|
+
|
|
89
|
+
Now let's look at the capturing group: `([a-zA-Z][a-zA-Z-]+[a-zA-Z])+`.
|
|
90
|
+
One thing that makes it easier to parse Regexp: substitute a class of characters with a simple character.
|
|
91
|
+
So let us substitute `a-zA-Z` with `x`.
|
|
92
|
+
Now we have: `(x[x-]+x)+`.
|
|
93
|
+
The capturing group inside the brackets match strings starting with an `x`, followed by an amount of x and hyphens, and ending with an `x`.
|
|
94
|
+
The shortest strings matching are:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
xxx
|
|
98
|
+
x-x
|
|
99
|
+
xxxx
|
|
100
|
+
xx-x
|
|
101
|
+
x-xx
|
|
102
|
+
x--x
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The capture group can be repeated one or more times. So for example: `x-x` followed by `x-x-xx` => `x-xx-x-xx`.
|
|
106
|
+
But no matter the combination of two or more of those matching strings, the original regex `x[x-]+x` in matches all of them as well, which makes the repetition unnecessary and expensive.
|
|
107
|
+
So let's simplify it:
|
|
108
|
+
|
|
109
|
+
```js
|
|
110
|
+
/([a-zA-Z][a-zA-Z-]+[a-zA-Z])+\.\w+/ // | \w and \w+ are equivalent here, because we do not care how many word chars come after the first one
|
|
111
|
+
/([a-zA-Z][a-zA-Z-]+[a-zA-Z])+\.\w/ // | Let's substitute `[a-zA-Z]` with `x`
|
|
112
|
+
/(x[x-]+x)+\.\w/ // | (x[x-]+x)+ and (x[x-]+x) are producing the same results here here
|
|
113
|
+
/(x[x-]+x)\.\w/ // | Let's substitute `x` with `[a-zA-Z]` and remove the unnecessary capturing
|
|
114
|
+
/[a-zA-Z][a-zA-Z-]+[a-zA-Z]\.\w/
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Results
|
|
118
|
+
|
|
119
|
+
`hyperfine --runs 3 "node ./checkAllStrings.js"` reports:
|
|
120
|
+
|
|
121
|
+
````
|
|
122
|
+
Benchmark 1: node ./checkAllStrings.js
|
|
123
|
+
Time (mean ± σ): 25.922 s ± 0.181 s [User: 23.802 s, System: 2.670 s]
|
|
124
|
+
Range (min … max): 25.726 s … 26.085 s 3 runs```
|
|
125
|
+
````
|
|
126
|
+
|
|
127
|
+
And 6 of our 13 strings are not timing out any longer. The next offender is `isKebabCase`. Let's have a look.
|
|
128
|
+
|
|
129
|
+
## `isKebabCase`
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
function isKebabCase(value) {
|
|
133
|
+
return (
|
|
134
|
+
/^([a-zA-Z0-9]+-[a-zA-Z0-9-]*[\s,]*)+$/.test(value) ||
|
|
135
|
+
/^([a-z0-9]+-[a-z0-9-]*)+(\s[a-z0-9-]*)*/.test(value) ||
|
|
136
|
+
/(\s[a-z0-9-]*)*([a-z0-9]+-[a-z0-9-]*)+$/.test(value)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The first regex is the offender here, but let us have a look at the last two first.
|
|
142
|
+
`^([a-z0-9]+-[a-z0-9-]*)+(\s[a-z0-9-]*)*` matches strings which starting with one or more `([a-z0-9]+-[a-z0-9-]*)`, followed by zero or more of `(\s[a-z0-9-]*)`.
|
|
143
|
+
Because we are not checking the complete string (missing `$` at the end), the latter is actually redundant, because for example: `a-b NONO` still tests true, despite `NONO` not matching that optional group.
|
|
144
|
+
|
|
145
|
+
If we substitute `[a-z0-9]` again with x, we get: `^(x+-[x-]*)+` for the first group.
|
|
146
|
+
As one match of the group, at the start of the string is enough, we can remove the capturing group and end up with `^x+-[x-]*`.
|
|
147
|
+
Now the last part of that group is also redundant, because we accept zero matches.
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
/^([a-z0-9]+-[a-z0-9-]*)+(\s[a-z0-9-]*)*/ // | `(\s[a-z0-9-]*)*` is redundant, because the test will succeed whether it is there or not
|
|
151
|
+
/^([a-z0-9]+-[a-z0-9-]*)+/ // | Let's substitute `[a-z0-9]` with `x`
|
|
152
|
+
/^(x+-[x-]*)+/ // | One match of the group is actually enough, so we can get rid of the capturing and +
|
|
153
|
+
/^x+-[x-]*/ // | Similar to our first step, `[x-]*` became redundant, because zero matches are also fine.
|
|
154
|
+
/^x+-/ // | Let's substitute `[a-z0-9]` back in
|
|
155
|
+
/^[a-z0-9]+-/
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Let's do the same for the last regex:
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
/(\s[a-z0-9-]*)*([a-z0-9]+-[a-z0-9-]*)+$/ // | Again, `(\s[a-z0-9-]*)*` is redundant, because the test will succeed whether it is there or not
|
|
162
|
+
/([a-z0-9]+-[a-z0-9-]*)+$/ // | Let's substitute `[a-z0-9]` with `x`
|
|
163
|
+
/(x+-[x-]*)+$/ // | One match of the group is actually enough, so we can get rid of the capturing and +
|
|
164
|
+
/x+-[x-]*$/ // | Let's substitute `[a-z0-9]` back in
|
|
165
|
+
/[a-z0-9]+-[a-z0-9-]*$/
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Now let us focus on the "beast" `/^([a-zA-Z0-9]+-[a-zA-Z0-9-]*[\s,]*)+$/`.
|
|
169
|
+
What is so interesting about this one? The `[\s,]*` bit is an optional list of whitespace characters and commas.
|
|
170
|
+
Essentially we want to check whether a list of values separated by those characters matches the rest.
|
|
171
|
+
So conceptually it isn't too different from:
|
|
172
|
+
|
|
173
|
+
```js
|
|
174
|
+
value.split(/[\s,]+/).every((item) => /^([a-zA-Z0-9]+-[a-zA-Z0-9-]*)+$/.test(item));
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
One caveat here: e.g. `foo-bar ,` would fail now because the split method actually would have the empty string `''` as the last value of the array.
|
|
178
|
+
However, we can build a little helper function:
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
// Hey, I am actually doing splitAndTrim, but who cares.
|
|
182
|
+
function trimAndSplit(value, separator, trimLeft = true, trimRight = true) {
|
|
183
|
+
const list = value.split(separator);
|
|
184
|
+
if (trimRight && list[list.length - 1] === '') {
|
|
185
|
+
list.pop();
|
|
186
|
+
}
|
|
187
|
+
if (trimLeft && list[0] === '') {
|
|
188
|
+
list.shift();
|
|
189
|
+
}
|
|
190
|
+
return list;
|
|
191
|
+
}
|
|
192
|
+
// Aaand
|
|
193
|
+
trimAndSplit(value, /[\s,]+/, false, true).every((item) =>
|
|
194
|
+
/^[a-zA-Z0-9]+-[a-zA-Z0-9-]*$/.test(item),
|
|
195
|
+
);
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Nice. Now if we look at the regex, our exercise will let us simplify the thing again, back to: `/^[a-zA-Z0-9]+-[a-zA-Z0-9-]*$/`.
|
|
199
|
+
|
|
200
|
+
Let's combine all the things, and now we have:
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
function isKebabCase(value) {
|
|
204
|
+
return (
|
|
205
|
+
/^[a-z0-9]+-/.test(value) ||
|
|
206
|
+
/[a-z0-9]+-[a-z0-9-]*$/.test(value) ||
|
|
207
|
+
trimAndSplit(value, /[\s,]+/, false, true).every((item) =>
|
|
208
|
+
/^[a-zA-Z0-9]+-[a-zA-Z0-9-]*$/.test(item),
|
|
209
|
+
)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
This solves the remaining checks which used to time out and hyperfine reports:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
$ hyperfine --runs 3 "node ./checkAllStrings.js"
|
|
218
|
+
|
|
219
|
+
Benchmark 1: node ./checkAllStrings.js
|
|
220
|
+
Time (mean ± σ): 15.141 s ± 0.015 s [User: 13.198 s, System: 2.514 s]
|
|
221
|
+
Range (min … max): 15.127 s … 15.158 s 3 runs
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
As we have no checks timing out any longer, also add a `--no-timeout` arg to checkAllStrings, as the timeout guard comes with a significant performance overhead.
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
$ hyperfine "node ./checkAllStrings.js --no-timeout"
|
|
228
|
+
Time (mean ± σ): 320.3 ms ± 0.8 ms [User: 324.3 ms, System: 10.5 ms]
|
|
229
|
+
Range (min … max): 319.0 ms … 321.4 ms 10 runs
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
What used to take _minutes_, now takes just 320 ms.
|
|
233
|
+
Eslint inside the GitLab project has finished on my machine and produced the following results:
|
|
234
|
+
|
|
235
|
+
- `isNonLocalizedStringValue` now takes 197ms instead of 2:15 minutes (0.06% vs 29%) of the total run time
|
|
236
|
+
- In CI the execution time went down from ~1400s to ~940s, which is about a 30% improvement and aligns with the results from the profiling.
|
|
237
|
+
|
|
238
|
+
For now, this is such a big improvement, that we can move forward with getting this merged and save loads of time and cpu cycles.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path';
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import functionTimeout from 'function-timeout';
|
|
7
|
+
|
|
8
|
+
import { isNonLocalizedStringValue } from '../../lib/utils/string-utils.js';
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
const allStrings = JSON.parse(await readFile(join(__dirname, 'allStrings.json'), 'utf8'));
|
|
13
|
+
|
|
14
|
+
const counts = {
|
|
15
|
+
TIMEOUT: 0,
|
|
16
|
+
TRUE: 0,
|
|
17
|
+
FALSE: 0,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
let resultWriter = null;
|
|
21
|
+
if (process.argv.includes('--write-results')) {
|
|
22
|
+
resultWriter = createWriteStream(
|
|
23
|
+
join(__dirname, `result-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const testFn = process.argv.includes('--no-timeout')
|
|
28
|
+
? isNonLocalizedStringValue
|
|
29
|
+
: functionTimeout(isNonLocalizedStringValue, { timeout: 1000 });
|
|
30
|
+
|
|
31
|
+
for (let i = 0, len = allStrings.length; i < len; i++) {
|
|
32
|
+
if (i % 10000 === 0) {
|
|
33
|
+
console.log(`Checked ${i} strings. Currently at '${allStrings[i]}'`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let result;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
result = testFn(allStrings[i]) ? 'TRUE' : 'FALSE';
|
|
40
|
+
} catch (e) {
|
|
41
|
+
result = 'TIMEOUT';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
counts[result] += 1;
|
|
45
|
+
|
|
46
|
+
resultWriter?.write(`${result}|${JSON.stringify(allStrings[i])}\n`);
|
|
47
|
+
}
|
|
48
|
+
console.log(`Checked ${allStrings.length} strings. Stats:`);
|
|
49
|
+
|
|
50
|
+
console.log(counts);
|
|
51
|
+
|
|
52
|
+
resultWriter?.close();
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join, dirname, extname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { globby } from 'globby';
|
|
5
|
+
import { parse } from '@babel/parser';
|
|
6
|
+
import traverse from '@babel/traverse';
|
|
7
|
+
import compiler from 'vue-template-compiler';
|
|
8
|
+
|
|
9
|
+
const globs = process.argv.slice(2);
|
|
10
|
+
|
|
11
|
+
const paths = await globby(globs, { absolute: true });
|
|
12
|
+
|
|
13
|
+
if (!paths.length) {
|
|
14
|
+
console.log('No files found, please provide globs of paths to scan as arguments:');
|
|
15
|
+
console.log(
|
|
16
|
+
"node scripts/benchmarking/getAllStrings.js '../path/to/gitlab/{ee/,}app/**/*.{js,vue}'",
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
console.log(`Extracting strings from ${paths.length} files.`);
|
|
21
|
+
async function checkConstructors(path) {
|
|
22
|
+
const ext = extname(path);
|
|
23
|
+
if (ext !== '.vue' && ext !== '.js') {
|
|
24
|
+
console.warn(`Skipping file with unsupported file type ('${ext}') ${path}`);
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
let content = await readFile(path, 'utf-8');
|
|
28
|
+
const literals = [];
|
|
29
|
+
|
|
30
|
+
const MyVisitor = {
|
|
31
|
+
StringLiteral({ node }) {
|
|
32
|
+
const value = node?.extra?.rawValue ?? node?.value;
|
|
33
|
+
if (value) {
|
|
34
|
+
literals.push(value);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
if (ext === '.vue') {
|
|
40
|
+
const source = compiler.parseComponent(content, { pad: 'line' });
|
|
41
|
+
|
|
42
|
+
if (source.template && source.template.content) {
|
|
43
|
+
const compiled = compiler.compile(source.template.content);
|
|
44
|
+
|
|
45
|
+
if (compiled.render) {
|
|
46
|
+
const tree = parse(compiled.render, { sourceType: 'script', errorRecovery: true });
|
|
47
|
+
traverse.default(tree, MyVisitor);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
compiled.staticRenderFns.forEach((fn) => {
|
|
51
|
+
const tree = parse(fn, { sourceType: 'script', errorRecovery: true });
|
|
52
|
+
traverse.default(tree, MyVisitor);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (source.script && source.script.content) {
|
|
57
|
+
content = source.script.content;
|
|
58
|
+
} else {
|
|
59
|
+
content = '';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const tree = parse(content, { sourceType: 'module' });
|
|
64
|
+
traverse.default(tree, MyVisitor);
|
|
65
|
+
|
|
66
|
+
return literals;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const data = (await Promise.all(paths.map(checkConstructors))).flat();
|
|
70
|
+
|
|
71
|
+
const allStrings = [...new Set(data)].sort();
|
|
72
|
+
|
|
73
|
+
const target = join(dirname(fileURLToPath(import.meta.url)), './allStrings.json');
|
|
74
|
+
|
|
75
|
+
console.log(`Found ${allStrings.length} unique strings. Wrote them to ${target}`);
|
|
76
|
+
|
|
77
|
+
await writeFile(target, JSON.stringify(allStrings), 'utf8');
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gitlab/eslint-plugin-benchmarking",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Benchmarking helpers for @gitlab/eslint-plugin",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": true,
|
|
8
|
+
"type": "module",
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"@babel/parser": "^7.21.4",
|
|
11
|
+
"@babel/traverse": "^7.21.4",
|
|
12
|
+
"function-timeout": "^0.1.1",
|
|
13
|
+
"globby": "^13.1.3",
|
|
14
|
+
"vue-template-compiler": "^2.7.14"
|
|
15
|
+
}
|
|
16
|
+
}
|