@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.
- package/CHANGELOG.md +10 -0
- package/LICENSE.txt +357 -0
- package/README.md +163 -0
- package/SECURITY.md +38 -0
- package/package.json +37 -0
- package/src/GettextEntries.js +97 -0
- package/src/GettextEntry.js +159 -0
- package/src/GettextExtractor.js +447 -0
- package/src/I18nCheckPlugin.js +341 -0
- package/src/plugin-name.js +1 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
const babel = require( '@babel/core' );
|
|
2
|
+
const npath = require( 'path' );
|
|
3
|
+
const webpack = require( 'webpack' );
|
|
4
|
+
const PLUGIN_NAME = require( './plugin-name.js' );
|
|
5
|
+
const GettextEntry = require( './GettextEntry.js' );
|
|
6
|
+
const GettextEntries = require( './GettextEntries.js' );
|
|
7
|
+
const GettextExtractor = require( './GettextExtractor.js' );
|
|
8
|
+
|
|
9
|
+
const debug = require( 'debug' )( PLUGIN_NAME + ':plugin' );
|
|
10
|
+
|
|
11
|
+
const schema = {
|
|
12
|
+
title: `${ PLUGIN_NAME } plugin options`,
|
|
13
|
+
type: 'object',
|
|
14
|
+
additionalProperties: false,
|
|
15
|
+
definitions: {
|
|
16
|
+
Filter: {
|
|
17
|
+
description: 'Filter for modules to process.',
|
|
18
|
+
anyOf: [
|
|
19
|
+
{
|
|
20
|
+
instanceof: 'RegExp',
|
|
21
|
+
tsType: 'RegExp',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
type: 'string',
|
|
25
|
+
absolutePath: false,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
instanceof: 'Function',
|
|
29
|
+
tsType: '((path: string) => boolean)',
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
properties: {
|
|
35
|
+
// General options.
|
|
36
|
+
filter: {
|
|
37
|
+
description: 'Filter which source modules to check for i18n strings.',
|
|
38
|
+
anyOf: [
|
|
39
|
+
{
|
|
40
|
+
$ref: '#/definitions/Filter',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: 'array',
|
|
44
|
+
items: {
|
|
45
|
+
$ref: '#/definitions/Filter',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
warnOnly: {
|
|
51
|
+
description: 'Set true to produce warnings rather than errors.',
|
|
52
|
+
type: 'boolean',
|
|
53
|
+
},
|
|
54
|
+
extractorOptions: {
|
|
55
|
+
description: 'Options for the extractor. Ignored if `extractor` was specified.',
|
|
56
|
+
type: 'object',
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
properties: {
|
|
59
|
+
babelOptions: {
|
|
60
|
+
description: 'Options for Babel',
|
|
61
|
+
type: 'object',
|
|
62
|
+
additionalProperties: true,
|
|
63
|
+
},
|
|
64
|
+
functions: {
|
|
65
|
+
description:
|
|
66
|
+
'Gettext functions to match. Key is the function name. Value is an array defining the arguments.',
|
|
67
|
+
type: 'object',
|
|
68
|
+
additionalProperties: {
|
|
69
|
+
description: 'Function with arguments.',
|
|
70
|
+
type: 'array',
|
|
71
|
+
items: {
|
|
72
|
+
description: 'Type of the argument.',
|
|
73
|
+
enum: [ 'msgid', 'plural', 'context', 'domain', null ],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Webpack plugin for the i18n check.
|
|
84
|
+
*
|
|
85
|
+
* The plugin hooks into Webpack to check that WordPress i18n wasn't
|
|
86
|
+
* mangled by optimizations.
|
|
87
|
+
*/
|
|
88
|
+
class I18nCheckPlugin {
|
|
89
|
+
#extractor;
|
|
90
|
+
#filter;
|
|
91
|
+
#reportkey;
|
|
92
|
+
|
|
93
|
+
constructor( options = {} ) {
|
|
94
|
+
webpack.validateSchema( schema, options, {
|
|
95
|
+
name: PLUGIN_NAME,
|
|
96
|
+
baseDataPath: 'options',
|
|
97
|
+
} );
|
|
98
|
+
|
|
99
|
+
this.#extractor = new GettextExtractor( options.extractorOptions );
|
|
100
|
+
this.#reportkey = options.warnOnly ? 'warnings' : 'errors';
|
|
101
|
+
|
|
102
|
+
if ( options.filter ) {
|
|
103
|
+
const filters = ( Array.isArray( options.filter ) ? options.filter : [ options.filter ] ).map(
|
|
104
|
+
filter => {
|
|
105
|
+
if ( typeof filter === 'string' ) {
|
|
106
|
+
return file => file === filter;
|
|
107
|
+
}
|
|
108
|
+
if ( filter instanceof RegExp ) {
|
|
109
|
+
return file => filter.test( file );
|
|
110
|
+
}
|
|
111
|
+
return filter;
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
this.#filter = file => filters.some( filter => filter( file ) );
|
|
115
|
+
} else {
|
|
116
|
+
this.#filter = file => /\.(?:jsx?|tsx?|cjs|mjs)$/.test( file );
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Record the resources for an asset.
|
|
122
|
+
*
|
|
123
|
+
* @param {webpack.Compilation} compilation - Compilation.
|
|
124
|
+
* @param {string} filename - Asset filename.
|
|
125
|
+
* @param {webpack.Module[]} modules - Modules in the asset.
|
|
126
|
+
*/
|
|
127
|
+
#recordResourcesForAsset( compilation, filename, modules ) {
|
|
128
|
+
const resources = new Set();
|
|
129
|
+
|
|
130
|
+
// Use a set to avoid processing the same module multiple times.
|
|
131
|
+
const modulesSet = new Set( modules );
|
|
132
|
+
for ( const module of modulesSet ) {
|
|
133
|
+
if (
|
|
134
|
+
module.resource &&
|
|
135
|
+
this.#filter( npath.relative( compilation.compiler.context, module.resource ) )
|
|
136
|
+
) {
|
|
137
|
+
resources.add( module.resource );
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Modules (e.g. ConcatenatedModules) might have sub-modules. Process them too.
|
|
141
|
+
if ( module.modules ) {
|
|
142
|
+
for ( const m of module.modules ) {
|
|
143
|
+
modulesSet.add( m );
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Update the asset.
|
|
149
|
+
compilation.updateAsset( filename, v => v, { resources: [ ...resources ] } );
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Stringify an entry to msgid + context.
|
|
154
|
+
*
|
|
155
|
+
* @param {GettextEntry} entry - Entry.
|
|
156
|
+
* @returns {string} String.
|
|
157
|
+
*/
|
|
158
|
+
#strentry( entry ) {
|
|
159
|
+
let ret = '"' + entry.msgid.replace( /[\\"]/g, '\\$&' ).replaceAll( '\n', '\\n' ) + '"';
|
|
160
|
+
if ( entry.context !== '' ) {
|
|
161
|
+
ret +=
|
|
162
|
+
' (context "' + entry.context.replace( /[\\"]/g, '\\$&' ).replaceAll( '\n', '\\n' ) + '")';
|
|
163
|
+
}
|
|
164
|
+
return ret;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
apply( compiler ) {
|
|
168
|
+
compiler.hooks.thisCompilation.tap( PLUGIN_NAME, compilation => {
|
|
169
|
+
// Record the resources going into an asset when the asset is created.
|
|
170
|
+
compilation.hooks.moduleAsset.tap( PLUGIN_NAME, ( module, filename ) => {
|
|
171
|
+
this.#recordResourcesForAsset( compilation, filename, [ module ] );
|
|
172
|
+
} );
|
|
173
|
+
compilation.hooks.chunkAsset.tap( PLUGIN_NAME, ( chunk, filename ) => {
|
|
174
|
+
this.#recordResourcesForAsset(
|
|
175
|
+
compilation,
|
|
176
|
+
filename,
|
|
177
|
+
compilation.chunkGraph.getChunkModules( chunk )
|
|
178
|
+
);
|
|
179
|
+
} );
|
|
180
|
+
|
|
181
|
+
// During the "analyze assets" step, check the assets.
|
|
182
|
+
const moduleCache = new Map();
|
|
183
|
+
compilation.hooks.processAssets.tapPromise(
|
|
184
|
+
{
|
|
185
|
+
name: PLUGIN_NAME,
|
|
186
|
+
stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE,
|
|
187
|
+
additionalAssets: true,
|
|
188
|
+
},
|
|
189
|
+
assets => {
|
|
190
|
+
const promises = [];
|
|
191
|
+
for ( const filename of Object.keys( assets ) ) {
|
|
192
|
+
promises.push( this.#processAsset( compilation, filename, moduleCache ) );
|
|
193
|
+
}
|
|
194
|
+
return Promise.all( promises );
|
|
195
|
+
}
|
|
196
|
+
);
|
|
197
|
+
} );
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Process an asset.
|
|
202
|
+
*
|
|
203
|
+
* @param {webpack.Compilation} compilation - Compilation.
|
|
204
|
+
* @param {string} filename - Asset filename.
|
|
205
|
+
* @param {Map} moduleCache - Cache for processed modules.
|
|
206
|
+
*/
|
|
207
|
+
async #processAsset( compilation, filename, moduleCache ) {
|
|
208
|
+
const asset = compilation.getAsset( filename );
|
|
209
|
+
|
|
210
|
+
// Detemine if we even need to process this asset. JavaScript assets seem to always
|
|
211
|
+
// have "javascriptModule" in their info, so look for that.
|
|
212
|
+
if ( typeof asset.info.javascriptModule === 'undefined' ) {
|
|
213
|
+
debug( `Asset ${ filename } does not seem to be JavaScript, skipping.` );
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if ( ! asset.info.resources || asset.info.resources.length <= 0 ) {
|
|
217
|
+
debug( `No resources associated with ${ filename }, skipping.` );
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Extract strings from the source resources.
|
|
222
|
+
const sourceEntries = new Set();
|
|
223
|
+
for ( const resource of asset.info.resources ) {
|
|
224
|
+
if ( ! moduleCache.has( resource ) ) {
|
|
225
|
+
const promise = new Promise( resolve => {
|
|
226
|
+
this.#extractor
|
|
227
|
+
.extractFromFile( resource, {
|
|
228
|
+
filename: npath.relative( compilation.compiler.context, resource ),
|
|
229
|
+
} )
|
|
230
|
+
.then( resolve );
|
|
231
|
+
} );
|
|
232
|
+
moduleCache.set( resource, promise );
|
|
233
|
+
}
|
|
234
|
+
const resourceEntries = await moduleCache.get( resource );
|
|
235
|
+
resourceEntries.forEach( e => sourceEntries.add( e ) );
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Extract strings from the asset.
|
|
239
|
+
const lintLogger = s => {
|
|
240
|
+
compilation[ this.#reportkey ].push( new Error( s ) );
|
|
241
|
+
};
|
|
242
|
+
const source = asset.source.source();
|
|
243
|
+
const babelFile = await this.#extractor.parse( source, { filename, lintLogger } );
|
|
244
|
+
const assetEntries = this.#extractor.extractFromAst( babelFile, { filename, lintLogger } );
|
|
245
|
+
|
|
246
|
+
// Analyze. First, collect the missing entries and report any entries with lost translator comments.
|
|
247
|
+
const missingEntries = new GettextEntries();
|
|
248
|
+
const didMissingCommentEntries = new Set();
|
|
249
|
+
for ( const entry of sourceEntries ) {
|
|
250
|
+
if ( ! assetEntries.has( entry ) ) {
|
|
251
|
+
missingEntries.add( entry );
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const assetComments = assetEntries.get( entry ).comments;
|
|
256
|
+
const missingComments = [ ...entry.comments ].filter( c => ! assetComments.has( c ) );
|
|
257
|
+
if ( missingComments.length ) {
|
|
258
|
+
const str = this.#strentry( entry );
|
|
259
|
+
if ( ! didMissingCommentEntries.has( str ) ) {
|
|
260
|
+
didMissingCommentEntries.add( str );
|
|
261
|
+
compilation[ this.#reportkey ].push(
|
|
262
|
+
new Error(
|
|
263
|
+
// prettier-ignore
|
|
264
|
+
`${ filename }: Translator comments have gone missing for ${ str }\n - ` + missingComments.join( '\n - ' )
|
|
265
|
+
)
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// For missing entries, ignore them if the msgid or context doesn't appear in the asset at all.
|
|
272
|
+
// In that case we assume they were removed by tree shaking rather than an optimization problem.
|
|
273
|
+
// Report any where the strings do still exist in the asset source.
|
|
274
|
+
if ( missingEntries.size > 0 ) {
|
|
275
|
+
const neededStrings = new Set();
|
|
276
|
+
missingEntries.forEach( entry => {
|
|
277
|
+
neededStrings.add( entry.msgid );
|
|
278
|
+
neededStrings.add( entry.context );
|
|
279
|
+
} );
|
|
280
|
+
|
|
281
|
+
const foundStrings = new Set( [ '' ] ); // Empty string is always "found", as that's the context for `__()` and `_n()`.
|
|
282
|
+
babel.traverse(
|
|
283
|
+
babelFile.ast,
|
|
284
|
+
{
|
|
285
|
+
'StringLiteral|TemplateLiteral': path => {
|
|
286
|
+
let s;
|
|
287
|
+
if ( babel.types.isStringLiteral( path.node ) ) {
|
|
288
|
+
s = path.node.value;
|
|
289
|
+
} else if ( path.node.expressions.length === 0 ) {
|
|
290
|
+
s = path.node.quasis[ 0 ].value.cookied;
|
|
291
|
+
} else {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if ( neededStrings.has( s ) ) {
|
|
295
|
+
foundStrings.add( s );
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
babelFile.source
|
|
300
|
+
);
|
|
301
|
+
const foundEntries = new GettextEntries();
|
|
302
|
+
missingEntries.forEach( entry => {
|
|
303
|
+
if ( foundStrings.has( entry.msgid ) && foundStrings.has( entry.context ) ) {
|
|
304
|
+
missingEntries.delete( entry );
|
|
305
|
+
foundEntries.add( entry );
|
|
306
|
+
}
|
|
307
|
+
} );
|
|
308
|
+
|
|
309
|
+
if ( foundEntries.size > 0 ) {
|
|
310
|
+
compilation[ this.#reportkey ].push(
|
|
311
|
+
new Error(
|
|
312
|
+
`${ filename }: Optimization seems to have broken the following translation strings:\n - ` +
|
|
313
|
+
Array.from( foundEntries.values(), this.#strentry ).sort().join( '\n - ' )
|
|
314
|
+
)
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if ( missingEntries.size > 0 ) {
|
|
318
|
+
debug(
|
|
319
|
+
`${ filename }: The following translation strings seem to have been removed entirely, or at least got mangled beyond recognition:\n` +
|
|
320
|
+
' - ' +
|
|
321
|
+
Array.from( missingEntries.values(), this.#strentry ).sort().join( '\n - ' )
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Check the number of domains used.
|
|
327
|
+
const domains = new Set();
|
|
328
|
+
assetEntries.forEach( e => domains.add( e.domain ) );
|
|
329
|
+
domains.delete( '' );
|
|
330
|
+
if ( domains.size > 1 ) {
|
|
331
|
+
compilation[ this.#reportkey ].push(
|
|
332
|
+
new Error(
|
|
333
|
+
// prettier-ignore
|
|
334
|
+
`${ filename }: Multiple textdomains are used: ${ Array.from( domains, JSON.stringify ).sort().join( ', ' ) }\nYou may want to use @automattic/babel-plugin-replace-textdomain to fix that.`
|
|
335
|
+
)
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = I18nCheckPlugin;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require( '../package.json' ).name;
|