@automattic/i18n-check-webpack-plugin 0.1.0

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.
@@ -0,0 +1,97 @@
1
+ const assert = require( 'assert/strict' );
2
+ const GettextEntry = require( './GettextEntry' );
3
+ const PROJECT_NAME = require( './plugin-name.js' );
4
+
5
+ /**
6
+ * A Set of GettextEntries.
7
+ */
8
+ class GettextEntries {
9
+ #map;
10
+
11
+ /**
12
+ * Constructor.
13
+ *
14
+ * @param {GettextEntry[]} [entries] - Initial entries.
15
+ */
16
+ constructor( entries = [] ) {
17
+ this.#map = new Map();
18
+ for ( const entry of entries ) {
19
+ this.add( entry );
20
+ }
21
+ }
22
+
23
+ get size() {
24
+ return this.#map.size;
25
+ }
26
+
27
+ #makekey( entry ) {
28
+ assert(
29
+ entry instanceof GettextEntry,
30
+ `Entry must be an instance of GettextEntry, got ${ typeof entry }`
31
+ );
32
+ return `${ entry.msgid }\0${ entry.context }`;
33
+ }
34
+
35
+ add( entry ) {
36
+ this.#map.set( this.#makekey( entry ), entry );
37
+ return this;
38
+ }
39
+
40
+ clear() {
41
+ this.#map.clear();
42
+ }
43
+
44
+ delete( entry ) {
45
+ return this.#map.delete( this.#makekey( entry ) );
46
+ }
47
+
48
+ *entries() {
49
+ for ( const entry of this.#map.values() ) {
50
+ yield [ entry, entry ];
51
+ }
52
+ }
53
+
54
+ forEach( fn, thisArg = undefined ) {
55
+ this.#map.forEach( v => fn.call( thisArg, v, v, this ) );
56
+ }
57
+
58
+ has( entry ) {
59
+ return this.#map.has( this.#makekey( entry ) );
60
+ }
61
+
62
+ get( entry ) {
63
+ return this.#map.get( this.#makekey( entry ) );
64
+ }
65
+
66
+ values() {
67
+ return this.#map.values();
68
+ }
69
+
70
+ keys = this.values;
71
+ [ Symbol.iterator ] = this.values;
72
+
73
+ toString() {
74
+ return (
75
+ `msgid ""
76
+ msgstr ""
77
+ "Project-Id-Version: \\n"
78
+ "Report-Msgid-Bugs-To: \\n"
79
+ "Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n"
80
+ "Language-Team: LANGUAGE <LL@li.org>\\n"
81
+ "MIME-Version: 1.0\\n"
82
+ "Content-Type: text/plain; charset=UTF-8\\n"
83
+ "Content-Transfer-Encoding: 8bit\\n"
84
+ "POT-Creation-Date: ${ new Date().toISOString() }\\n"
85
+ "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n"
86
+ "X-Generator: ${ PROJECT_NAME } GettextEntries.js\\n"
87
+
88
+ `.replace( /^\t+/gm, '' ) + Array.from( this.values() ).join( '\n' )
89
+ );
90
+ }
91
+
92
+ toJSON() {
93
+ return Array.from( this.values() );
94
+ }
95
+ }
96
+
97
+ module.exports = GettextEntries;
@@ -0,0 +1,159 @@
1
+ const assert = require( 'assert/strict' );
2
+
3
+ class StringSet extends Set {
4
+ add( v ) {
5
+ assert( typeof v === 'string', `Value must be a string, got ${ typeof v }` );
6
+ assert( ! /[\r\n]/.test( v ), 'Value may not contain newlines' );
7
+ super.add( v );
8
+ }
9
+ }
10
+
11
+ /**
12
+ * Represents a single translation string.
13
+ */
14
+ class GettextEntry {
15
+ #msgid;
16
+ #plural;
17
+ #context;
18
+ #domain;
19
+ #locations = new StringSet();
20
+ #comments = new StringSet();
21
+
22
+ /**
23
+ * Constructor.
24
+ *
25
+ * @param {object} data - Entry data.
26
+ * @param {string} data.msgid - Message string.
27
+ * @param {string} data.plural - Plural string.
28
+ * @param {string} data.context - Context.
29
+ * @param {string} data.domain - Text domain.
30
+ * @param {Iterable<string>} data.comments - Comments.
31
+ * @param {Iterable<string>} data.locations - Locations.
32
+ */
33
+ constructor( data ) {
34
+ this.#msgid = data.msgid;
35
+ this.#plural = data.plural;
36
+ this.#context = data.context || '';
37
+ this.#domain = data.domain || '';
38
+
39
+ assert(
40
+ typeof this.#msgid === 'string',
41
+ `data.msgid must be a string, got ${ typeof data.msgid }`
42
+ );
43
+ assert(
44
+ typeof this.#plural === 'string' || typeof data.plural === 'undefined',
45
+ `data.plural must be a string, got ${ typeof data.plural }`
46
+ );
47
+ assert(
48
+ typeof this.#context === 'string',
49
+ `data.context must be a string, got ${ typeof data.context }`
50
+ );
51
+ assert(
52
+ typeof this.#domain === 'string',
53
+ `data.domain must be a string, got ${ typeof data.domain }`
54
+ );
55
+
56
+ if ( data.comments ) {
57
+ assert(
58
+ typeof data.comments[ Symbol.iterator ] === 'function',
59
+ `data.comments must be an Iterable of single-line strings, got ${ typeof data.comments }`
60
+ );
61
+ for ( const s of data.comments ) {
62
+ assert(
63
+ typeof s === 'string',
64
+ `data.comments must be an Iterable of single-line strings, got an Iterable containing ${ typeof s }`
65
+ );
66
+ assert(
67
+ ! /[\r\n]/.test( s ),
68
+ `data.comments must be an Iterable of single-line strings, got an Iterable containing a multi-line string`
69
+ );
70
+ this.comments.add( s );
71
+ }
72
+ }
73
+
74
+ if ( data.locations ) {
75
+ assert(
76
+ typeof data.locations[ Symbol.iterator ] === 'function',
77
+ `data.locations must be an Iterable of single-line strings, got ${ typeof data.locations }`
78
+ );
79
+ for ( const s of data.locations ) {
80
+ assert(
81
+ typeof s === 'string',
82
+ `data.locations must be an Iterable of single-line strings, got an Iterable containing ${ typeof s }`
83
+ );
84
+ assert(
85
+ ! /[\r\n]/.test( s ),
86
+ `data.locations must be an Iterable of single-line strings, got an Iterable containing a multi-line string`
87
+ );
88
+ this.locations.add( s );
89
+ }
90
+ }
91
+ }
92
+
93
+ get msgid() {
94
+ return this.#msgid;
95
+ }
96
+ get plural() {
97
+ return this.#plural;
98
+ }
99
+ get context() {
100
+ return this.#context;
101
+ }
102
+ get domain() {
103
+ return this.#domain;
104
+ }
105
+ get comments() {
106
+ return this.#comments;
107
+ }
108
+ get locations() {
109
+ return this.#locations;
110
+ }
111
+
112
+ #enc( s ) {
113
+ return (
114
+ ( s.indexOf( '\n' ) < 0 ? '' : '""\n' ) +
115
+ `"${ s.replace( /[\\"]/g, '\\$&' ).split( '\n' ).join( '\\n"\n"' ) }"\n`
116
+ );
117
+ }
118
+
119
+ toString() {
120
+ let ret = '';
121
+
122
+ if ( this.domain !== '' ) {
123
+ ret += `# domain: ${ this.domain }\n`;
124
+ }
125
+ for ( const c of this.comments ) {
126
+ ret += `#. ${ c }\n`;
127
+ }
128
+ for ( const l of this.locations ) {
129
+ ret += `#: ${ l }\n`;
130
+ }
131
+ if ( this.context !== '' ) {
132
+ ret += 'msgctxt ' + this.#enc( this.context );
133
+ }
134
+ ret += 'msgid ' + this.#enc( this.msgid );
135
+ if ( this.plural ) {
136
+ ret += 'msgid_plural ' + this.#enc( this.plural );
137
+ ret += 'msgstr[0] ""\nmsgstr[1] ""\n';
138
+ } else {
139
+ ret += 'msgstr ""\n';
140
+ }
141
+
142
+ return ret;
143
+ }
144
+
145
+ toJSON() {
146
+ return Object.fromEntries(
147
+ Object.entries( {
148
+ msgid: this.msgid,
149
+ plural: this.plural,
150
+ context: this.context === '' ? undefined : this.context,
151
+ domain: this.domain === '' ? undefined : this.domain,
152
+ comments: this.comments.size ? [ ...this.comments ] : undefined,
153
+ locations: this.locations.size ? [ ...this.locations ] : undefined,
154
+ } ).filter( e => typeof e[ 1 ] !== 'undefined' )
155
+ );
156
+ }
157
+ }
158
+
159
+ module.exports = GettextEntry;
@@ -0,0 +1,447 @@
1
+ const fs = require( 'fs/promises' );
2
+ const babel = require( '@babel/core' );
3
+ const PLUGIN_NAME = require( './plugin-name.js' );
4
+ const GettextEntries = require( './GettextEntries' );
5
+ const GettextEntry = require( './GettextEntry' );
6
+
7
+ const { parseAsync, parseSync, traverse, types: t } = babel;
8
+ const debug = require( 'debug' )( `${ PLUGIN_NAME }:gettext` );
9
+
10
+ // Some typedefs for jsdoc. Babel doesn't export the actual constructors for us.
11
+ /** @typedef babel.Node */
12
+ /** @typedef babel.Comment */
13
+ /** @typedef {babel.Node} babel.CallExpression */
14
+
15
+ /**
16
+ * Default function mappings.
17
+ *
18
+ * Key is the function. Value specifies whether each argument holds the
19
+ * msgid, plural, context, or domain.
20
+ */
21
+ const defaultFunctions = Object.freeze( {
22
+ __: [ 'msgid', 'domain' ],
23
+ _x: [ 'msgid', 'context', 'domain' ],
24
+ _n: [ 'msgid', 'plural', null, 'domain' ],
25
+ _nx: [ 'msgid', 'plural', null, 'context', 'domain' ],
26
+ } );
27
+
28
+ /**
29
+ * A gettext-style string extractor compatible with that used by wp-cli.
30
+ *
31
+ * @see https://github.com/wp-cli/i18n-command/
32
+ */
33
+ class GettextExtractor {
34
+ #babelOptions;
35
+ #functions;
36
+ #lintlogger;
37
+
38
+ /**
39
+ * Constructor.
40
+ *
41
+ * @param {object} options - Configuration options.
42
+ * @param {object} options.babelOptions - Options for Babel.
43
+ * @param {object<string, (string | null)[]>} options.functions - Functions to extract. Defaults are available as a static property `defaultFunctions`.
44
+ * @param {Function} options.lintLogger - Lint logging callback. See `this.setLintLogger()`.
45
+ */
46
+ constructor( options = {} ) {
47
+ this.#babelOptions = options.babelOptions || {};
48
+ this.#functions = options.functions || defaultFunctions;
49
+ this.#lintlogger = options.lintLogger || null;
50
+ }
51
+
52
+ /**
53
+ * Extract gettext strings from a file.
54
+ *
55
+ * @param {string} file - File name.
56
+ * @param {object} opts - Babel options.
57
+ * @returns {GettextEntries} Set of entries.
58
+ */
59
+ async extractFromFile( file, opts = {} ) {
60
+ const contents = await fs.readFile( file, { encoding: 'utf8' } );
61
+ return await this.extract( contents, { filename: file, ...opts } );
62
+ }
63
+
64
+ /**
65
+ * Parse source.
66
+ *
67
+ * @param {string} source - JavaScript source.
68
+ * @param {object} opts - Babel options.
69
+ * @returns {babel.File} Babel File object.
70
+ */
71
+ async parse( source, opts = {} ) {
72
+ const options = { ...this.#babelOptions, ...opts };
73
+ delete options.lintLogger;
74
+ const ast = await parseAsync( source, options );
75
+ return new babel.File( options, { code: source, ast } );
76
+ }
77
+
78
+ /**
79
+ * Extract gettext strings from source.
80
+ *
81
+ * @param {string} source - JavaScript source.
82
+ * @param {object} opts - Babel options.
83
+ * @returns {GettextEntries} Set of entries.
84
+ */
85
+ async extract( source, opts ) {
86
+ return this.extractFromAst( await this.parse( source, opts ), opts );
87
+ }
88
+
89
+ /**
90
+ * Extract gettext strings from a Babel File object.
91
+ *
92
+ * @param {babel.File} file - Babel File object, e.g. from `this.parse()`.
93
+ * @param {object} opts - Babel options.
94
+ * @returns {GettextEntries} Set of entries.
95
+ */
96
+ extractFromAst( file, opts = {} ) {
97
+ const entries = new GettextEntries();
98
+ return this.#extractFromAst( file, entries, false, opts );
99
+ }
100
+
101
+ /**
102
+ * Extract gettext strings from a Babel File object.
103
+ *
104
+ * @param {babel.File} file - Babel File object, e.g. from `this.parse()`.
105
+ * @param {GettextEntries} entries - Entries object to fill in.
106
+ * @param {number|false} evalline - If this is a recursive call from an `eval()`, the line of the eval.
107
+ * @param {object} opts - Babel options.
108
+ * @returns {GettextEntries} `entries`.
109
+ */
110
+ #extractFromAst( file, entries, evalline, opts ) {
111
+ const options = { ...this.#babelOptions, ...opts };
112
+ delete options.lintLogger;
113
+ const filename = opts.filename ? opts.filename + ':' : '';
114
+ const allComments = new Set();
115
+ const commentsCache = new WeakSet();
116
+
117
+ let lintlogger = typeof opts.lintLogger !== 'undefined' ? opts.lintLogger : this.#lintlogger;
118
+ let dolint = true;
119
+ if ( ! lintlogger ) {
120
+ lintlogger = debug;
121
+ dolint = debug.enabled;
122
+ }
123
+
124
+ traverse(
125
+ file.ast,
126
+ {
127
+ // Unfortunately we have to visit every node to collect comments.
128
+ // @see https://github.com/wp-cli/i18n-command/blob/e9eef8aab4b5e43c3aa09bf60e1e7a9d6d30d302/src/JsFunctionsScanner.php#L73
129
+ // @see https://github.com/wp-cli/i18n-command/blob/e9eef8aab4b5e43c3aa09bf60e1e7a9d6d30d302/src/JsFunctionsScanner.php#L208
130
+ enter: path => {
131
+ const node = path.node;
132
+
133
+ if ( node.leadingComments ) {
134
+ node.leadingComments.forEach( c => allComments.add( c ) );
135
+ }
136
+
137
+ if ( ! t.isCallExpression( node ) ) {
138
+ return;
139
+ }
140
+
141
+ const callee = this.#resolveExpressionCallee( node );
142
+ if ( ! callee ) {
143
+ return;
144
+ }
145
+
146
+ // If we see an `eval()`, process the contents.
147
+ if ( callee.name === 'eval' ) {
148
+ for ( const arg of node.arguments ) {
149
+ if ( t.isLiteral( arg ) ) {
150
+ const ast = parseSync( arg.value, options );
151
+ const evalfile = new babel.File( options, { code: arg.value, ast } );
152
+ this.#extractFromAst( evalfile, entries, evalline || node.loc.start.line, opts );
153
+ break;
154
+ }
155
+ }
156
+ return;
157
+ }
158
+
159
+ if ( ! this.#functions.hasOwnProperty( callee.name ) ) {
160
+ return;
161
+ }
162
+
163
+ // Support nested function calls.
164
+ if ( node.leadingComments ) {
165
+ for ( const arg of node.arguments ) {
166
+ arg.leadingComments = arg.leadingComments
167
+ ? arg.leadingComments.concat( node.leadingComments )
168
+ : node.leadingComments;
169
+ }
170
+ }
171
+
172
+ callee.comments.forEach( c => allComments.add( c ) );
173
+
174
+ // Yes, this doesn't quite match the code from wp-cli/i18n-command. Theirs is buggy.
175
+ const args = [];
176
+ for ( const arg of node.arguments ) {
177
+ if (
178
+ arg.leadingComments &&
179
+ // The mck89/peast parser used by wp-cli/i18n-command attaches the comment to the callee of the call expression,
180
+ // while Babel's attaches it to the expression itself. Avoid adding things here.
181
+ ! t.isCallExpression( arg )
182
+ ) {
183
+ arg.leadingComments.forEach( c => allComments.add( c ) );
184
+ }
185
+
186
+ if (
187
+ t.isLiteral( arg ) &&
188
+ ! t.isTemplateLiteral( arg ) &&
189
+ ! t.isRegExpLiteral( arg )
190
+ ) {
191
+ args.push( arg.value );
192
+ } else if ( t.isTemplateLiteral( arg ) && arg.expressions.length === 0 ) {
193
+ args.push( arg.quasis[ 0 ].value.cooked );
194
+ } else {
195
+ args.push( null );
196
+ }
197
+ }
198
+
199
+ const entryData = {
200
+ comments: [],
201
+ };
202
+ for ( const [ i, k ] of Object.entries( this.#functions[ callee.name ] ) ) {
203
+ entryData[ k ] = args[ i ];
204
+ }
205
+
206
+ if ( entryData.msgid === '' ) {
207
+ if ( dolint ) {
208
+ const loc = filename + node.loc.start.line + ':' + ( node.loc.start.column + 1 );
209
+ const str = path.getSource();
210
+ lintlogger( `${ loc }: msgid is empty: ${ str }` );
211
+ }
212
+ return;
213
+ }
214
+
215
+ // While wp-cli will stringify numbers and `true`, that's kind of pointless (and will break the check plugin) so let's not.
216
+ if ( typeof entryData.msgid !== 'string' ) {
217
+ if ( dolint ) {
218
+ const loc = filename + node.loc.start.line + ':' + ( node.loc.start.column + 1 );
219
+ const str = path.getSource();
220
+ const idx = this.#functions[ callee.name ].indexOf( 'msgid' );
221
+ if ( node.arguments[ idx ] ) {
222
+ lintlogger( `${ loc }: msgid argument is not a string literal: ${ str }` );
223
+ } else {
224
+ lintlogger( `${ loc }: msgid argument (index ${ idx + 1 }) is missing: ${ str }` );
225
+ }
226
+ }
227
+ return;
228
+ }
229
+
230
+ // None of the rest of the fields prevent it from being processed, the invalid value is just stringified.
231
+ if ( entryData.domain === '' && dolint ) {
232
+ const loc = filename + node.loc.start.line + ':' + ( node.loc.start.column + 1 );
233
+ const str = path.getSource();
234
+ lintlogger( `${ loc }: domain is empty: ${ str }` );
235
+ }
236
+ for ( const k of [ 'plural', 'context', 'domain' ] ) {
237
+ if (
238
+ typeof entryData[ k ] !== 'string' &&
239
+ this.#functions[ callee.name ].indexOf( k ) >= 0
240
+ ) {
241
+ if ( dolint ) {
242
+ const loc = filename + node.loc.start.line + ':' + ( node.loc.start.column + 1 );
243
+ const str = path.getSource();
244
+ const idx = this.#functions[ callee.name ].indexOf( k );
245
+ if ( node.arguments[ idx ] ) {
246
+ lintlogger( `${ loc }: ${ k } argument is not a string literal: ${ str }` );
247
+ } else {
248
+ lintlogger(
249
+ `${ loc }: ${ k } argument (index ${ idx + 1 }) is missing: ${ str }`
250
+ );
251
+ }
252
+ }
253
+ if ( entryData[ k ] === true ) {
254
+ entryData[ k ] = '1';
255
+ } else if (
256
+ entryData[ k ] === false ||
257
+ entryData[ k ] === null ||
258
+ typeof entryData[ k ] === 'undefined'
259
+ ) {
260
+ delete entryData[ k ];
261
+ } else {
262
+ entryData[ k ] = entryData[ k ].toString();
263
+ }
264
+ }
265
+ }
266
+
267
+ let entry = new GettextEntry( entryData );
268
+ if ( entries.has( entry ) ) {
269
+ entry = entries.get( entry );
270
+ } else {
271
+ entries.add( entry );
272
+ }
273
+ if ( filename !== '' ) {
274
+ entry.locations.add( filename + ( evalline || node.loc.start.line ) );
275
+ }
276
+
277
+ let parsedComment = false;
278
+ for ( const comment of allComments ) {
279
+ if ( ! this.#commentPrecedesNode( comment, node ) ) {
280
+ continue;
281
+ }
282
+
283
+ if ( commentsCache.has( comment ) ) {
284
+ continue;
285
+ }
286
+
287
+ parsedComment = comment.value
288
+ .split( '\n' )
289
+ .map( l => l.replace( /^[#*/ \t\r\n\0\v]+|[#*/ \t\r\n\0\v]+$/g, '' ) )
290
+ .filter( v => v !== '' )
291
+ .join( ' ' );
292
+ if ( /^[tT]ranslators/.test( parsedComment ) ) {
293
+ entry.comments.add( parsedComment );
294
+ commentsCache.add( comment );
295
+ }
296
+ }
297
+
298
+ if ( parsedComment ) {
299
+ allComments.clear();
300
+ }
301
+ },
302
+ },
303
+ file.scope
304
+ );
305
+
306
+ return entries;
307
+ }
308
+
309
+ /**
310
+ * Resolve the callee of a CallExpression.
311
+ *
312
+ * @see https://github.com/wp-cli/i18n-command/blob/e9eef8aab4b5e43c3aa09bf60e1e7a9d6d30d302/src/JsFunctionsScanner.php#L254
313
+ * @param {babel.CallExpression} node - CallExpression node.
314
+ * @returns {{ name: string, comments: string[] }|undefined} Callee name and comments, or undefined.
315
+ */
316
+ #resolveExpressionCallee( node ) {
317
+ const callee = node.callee;
318
+
319
+ // If the callee is a simple identifier it can simply be returned.
320
+ // For example: __( "translation" ).
321
+ if ( t.isIdentifier( callee ) ) {
322
+ return {
323
+ name: callee.name,
324
+ comments: callee.leadingComments || [],
325
+ };
326
+ }
327
+
328
+ // If the callee is a member expression resolve it to the property.
329
+ // For example: wp.i18n.__( "translation" ) or u.__( "translation" ).
330
+ if ( t.isMemberExpression( callee ) && t.isIdentifier( callee.property ) ) {
331
+ // Make sure to unpack wp.i18n which is a nested MemberExpression.
332
+ const comments = t.isMemberExpression( callee.object )
333
+ ? callee.object.object.leadingComments
334
+ : callee.object.leadingComments;
335
+ return {
336
+ name: callee.property.name,
337
+ comments: comments || [],
338
+ };
339
+ }
340
+
341
+ // If the callee is a call expression as created by Webpack resolve it.
342
+ // For example: Object(u.__)( "translation" ).
343
+ if (
344
+ t.isCallExpression( callee ) &&
345
+ t.isIdentifier( callee.callee ) &&
346
+ callee.callee.name === 'Object' &&
347
+ callee.arguments.length > 0 &&
348
+ t.isMemberExpression( callee.arguments[ 0 ] )
349
+ ) {
350
+ const property = callee.arguments[ 0 ].property;
351
+
352
+ // Matches minified webpack statements: Object(u.__)( "translation" ).
353
+ if ( t.isIdentifier( property ) ) {
354
+ return {
355
+ name: property.name,
356
+ comments: callee.callee.leadingComments || [],
357
+ };
358
+ }
359
+
360
+ // Matches unminified webpack statements:
361
+ // Object(_wordpress_i18n__WEBPACK_IMPORTED_MODULE_7__["__"])( "translation" );
362
+ if ( t.isStringLiteral( property ) ) {
363
+ let name = property.value;
364
+
365
+ // Matches mangled webpack statement:
366
+ // Object(_wordpress_i18n__WEBPACK_IMPORTED_MODULE_7__[/* __ */ "a"])( "translation" );
367
+ const leadingPropertyComments = property.leadingComments;
368
+ if (
369
+ leadingPropertyComments &&
370
+ leadingPropertyComments.length === 1 &&
371
+ leadingPropertyComments[ 0 ].type === 'CommentBlock'
372
+ ) {
373
+ name = leadingPropertyComments[ 0 ].value.trim();
374
+ }
375
+
376
+ return {
377
+ name: name,
378
+ comments: callee.callee.leadingComments || [],
379
+ };
380
+ }
381
+ }
382
+
383
+ // If the callee is an indirect function call as created by babel, resolve it.
384
+ // For example: `(0, u.__)( "translation" )`.
385
+ // Local change: Apparently Babel's parser doesn't reproduce the ParenthesizedExpression?
386
+ let expressions;
387
+ if ( t.isParenthesizedExpression( callee ) && t.isSequenceExpression( callee.expression ) ) {
388
+ expressions = callee.expression.expressions;
389
+ } else if ( t.isSequenceExpression( callee ) ) {
390
+ expressions = callee.expressions;
391
+ }
392
+ if (
393
+ expressions &&
394
+ expressions.length === 2 &&
395
+ t.isLiteral( expressions[ 0 ] ) &&
396
+ node.arguments.length > 0
397
+ ) {
398
+ // Matches any general indirect function call: `(0, __)( "translation" )`.
399
+ if ( t.isIdentifier( expressions[ 1 ] ) ) {
400
+ return {
401
+ name: expressions[ 1 ].name,
402
+ comments: callee.leadingComments || [],
403
+ };
404
+ }
405
+
406
+ // Matches indirect function calls used by babel for module imports: `(0, _i18n.__)( "translation" )`.
407
+ if ( t.isMemberExpression( expressions[ 1 ] ) ) {
408
+ const property = expressions[ 1 ].property;
409
+
410
+ if ( t.isIdentifier( property ) ) {
411
+ return {
412
+ name: property.name,
413
+ comments: callee.leadingComments || [],
414
+ };
415
+ }
416
+ }
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Test if the comment comes before the node.
422
+ *
423
+ * @see https://github.com/wp-cli/i18n-command/blob/e9eef8aab4b5e43c3aa09bf60e1e7a9d6d30d302/src/JsFunctionsScanner.php#L364
424
+ * @param {babel.Comment} comment - Comment.
425
+ * @param {babel.Node} node - Node.
426
+ * @returns {boolean} Whether the comment comes before the node.
427
+ */
428
+ #commentPrecedesNode( comment, node ) {
429
+ // Comments should be on the same or an earlier line than the translation.
430
+ if ( node.loc.start.line - comment.loc.end.line > 1 ) {
431
+ return false;
432
+ }
433
+
434
+ // Comments on the same line should be before the translation.
435
+ if (
436
+ node.loc.start.line === comment.loc.end.line &&
437
+ node.loc.start.column < comment.loc.start.column
438
+ ) {
439
+ return false;
440
+ }
441
+
442
+ return true;
443
+ }
444
+ }
445
+
446
+ module.exports = GettextExtractor;
447
+ module.exports.defaultFunctions = defaultFunctions;