Skip to content

Commit 6c92f4a

Browse files
committed
📦 NEW: First pass on translation file generation
1 parent 3b74157 commit 6c92f4a

1 file changed

Lines changed: 368 additions & 0 deletions

File tree

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
/**
2+
* Credits:
3+
*
4+
* babel-gettext-extractor
5+
* https://github.com/getsentry/babel-gettext-extractor
6+
*
7+
* The MIT License (MIT)
8+
*
9+
* Copyright (c) 2015 jruchaud
10+
* Copyright (c) 2015 Sentry
11+
*
12+
* Permission is hereby granted, free of charge, to any person obtaining a copy
13+
* of this software and associated documentation files (the "Software"), to deal
14+
* in the Software without restriction, including without limitation the rights
15+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16+
* copies of the Software, and to permit persons to whom the Software is
17+
* furnished to do so, subject to the following conditions:
18+
*
19+
* The above copyright notice and this permission notice shall be included in all
20+
* copies or substantial portions of the Software.
21+
*
22+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28+
* SOFTWARE.
29+
*/
30+
31+
/**
32+
* External dependencies
33+
*/
34+
35+
const { po } = require( 'gettext-parser' );
36+
const {
37+
pick,
38+
reduce,
39+
uniq,
40+
forEach,
41+
sortBy,
42+
isEqual,
43+
merge,
44+
isEmpty,
45+
} = require( 'lodash' );
46+
const { relative, sep } = require( 'path' );
47+
const { writeFileSync } = require( 'fs' );
48+
49+
/**
50+
* Default output headers if none specified in plugin options.
51+
*
52+
* @type {Object}
53+
*/
54+
const DEFAULT_HEADERS = {
55+
'content-type': 'text/plain; charset=UTF-8',
56+
'x-generator': 'babel-plugin-wp-i18n',
57+
};
58+
59+
/**
60+
* Default functions to parse if none specified in plugin options. Each key is
61+
* a CallExpression name (or member name) and the value an array corresponding
62+
* to translation key argument position.
63+
*
64+
* @type {Object}
65+
*/
66+
const DEFAULT_FUNCTIONS = {
67+
__: [ 'msgid' ],
68+
_n: [ 'msgid', 'msgid_plural' ],
69+
_x: [ 'msgid', 'msgctxt' ],
70+
_nx: [ 'msgid', 'msgctxt', 'msgid_plural' ],
71+
};
72+
73+
/**
74+
* Default file output if none specified.
75+
*
76+
* @type {string}
77+
*/
78+
const DEFAULT_OUTPUT = 'gettext.pot';
79+
80+
/**
81+
* Set of keys which are valid to be assigned into a translation object.
82+
*
83+
* @type {string[]}
84+
*/
85+
const VALID_TRANSLATION_KEYS = [ 'msgid', 'msgid_plural', 'msgctxt' ];
86+
87+
/**
88+
* Regular expression matching translator comment value.
89+
*
90+
* @type {RegExp}
91+
*/
92+
const REGEXP_TRANSLATOR_COMMENT = /^\s*translators:\s*([\s\S]+)/im;
93+
94+
/**
95+
* Given an argument node (or recursed node), attempts to return a string
96+
* represenation of that node's value.
97+
*
98+
* @param {Object} node AST node.
99+
*
100+
* @returns {string} String value.
101+
*/
102+
function getNodeAsString( node ) {
103+
switch ( node.type ) {
104+
case 'BinaryExpression':
105+
return getNodeAsString( node.left ) + getNodeAsString( node.right );
106+
107+
case 'StringLiteral':
108+
return node.value;
109+
110+
default:
111+
return '';
112+
}
113+
}
114+
115+
/**
116+
* Returns translator comment for a given AST traversal path if one exists.
117+
*
118+
* @param {Object} path Traversal path.
119+
* @param {number} _originalNodeLine Private: In recursion, line number of
120+
* the original node passed.
121+
*
122+
* @returns {?string} Translator comment.
123+
*/
124+
function getTranslatorComment( path, _originalNodeLine ) {
125+
const { node, parent, parentPath } = path;
126+
127+
// Assign original node line so we can keep track in recursion whether a
128+
// matched comment or parent occurs on the same or previous line
129+
if ( ! _originalNodeLine ) {
130+
_originalNodeLine = node.loc.start.line;
131+
}
132+
133+
let comment;
134+
forEach( node.leadingComments, commentNode => {
135+
const { line } = commentNode.loc.end;
136+
if ( line < _originalNodeLine - 1 || line > _originalNodeLine ) {
137+
return;
138+
}
139+
140+
const match = commentNode.value.match( REGEXP_TRANSLATOR_COMMENT );
141+
if ( match ) {
142+
// Extract text from matched translator prefix
143+
comment = match[ 1 ]
144+
.split( '\n' )
145+
.map( text => text.trim() )
146+
.join( ' ' );
147+
148+
// False return indicates to Lodash to break iteration
149+
return false;
150+
}
151+
} );
152+
153+
if ( comment ) {
154+
return comment;
155+
}
156+
157+
if ( ! parent || ! parent.loc || ! parentPath ) {
158+
return;
159+
}
160+
161+
// Only recurse as long as parent node is on the same or previous line
162+
const { line } = parent.loc.start;
163+
if ( line >= _originalNodeLine - 1 && line <= _originalNodeLine ) {
164+
return getTranslatorComment( parentPath, _originalNodeLine );
165+
}
166+
}
167+
168+
/**
169+
* Returns true if the specified key of a function is valid for assignment in
170+
* the translation object.
171+
*
172+
* @param {string} key Key to test.
173+
*
174+
* @returns {boolean} Whether key is valid for assignment.
175+
*/
176+
function isValidTranslationKey( key ) {
177+
return -1 !== VALID_TRANSLATION_KEYS.indexOf( key );
178+
}
179+
180+
/**
181+
* Given two translation objects, returns true if valid translation keys match,
182+
* or false otherwise.
183+
*
184+
* @param {Object} a First translation object.
185+
* @param {Object} b Second translation object.
186+
*
187+
* @returns {boolean} Whether valid translation keys match.
188+
*/
189+
function isSameTranslation( a, b ) {
190+
return isEqual(
191+
pick( a, VALID_TRANSLATION_KEYS ),
192+
pick( b, VALID_TRANSLATION_KEYS )
193+
);
194+
}
195+
196+
module.exports = function() {
197+
const strings = {};
198+
let nplurals = 2,
199+
baseData;
200+
201+
return {
202+
visitor: {
203+
CallExpression( path, state ) {
204+
const { callee } = path.node;
205+
206+
// Determine function name by direct invocation or property name
207+
let name;
208+
if ( 'MemberExpression' === callee.type ) {
209+
name = callee.property.name;
210+
} else {
211+
name = callee.name;
212+
}
213+
214+
// Skip unhandled functions
215+
const functionKeys = ( state.opts.functions || DEFAULT_FUNCTIONS )[ name ];
216+
if ( ! functionKeys ) {
217+
return;
218+
}
219+
220+
// Assign translation keys by argument position
221+
const translation = path.node.arguments.reduce( ( memo, arg, i ) => {
222+
const key = functionKeys[ i ];
223+
if ( isValidTranslationKey( key ) ) {
224+
memo[ key ] = getNodeAsString( arg );
225+
}
226+
227+
return memo;
228+
}, {} );
229+
230+
// Can only assign translation with usable msgid
231+
if ( ! translation.msgid ) {
232+
return;
233+
}
234+
235+
// At this point we assume we'll save data, so initialize if
236+
// we haven't already
237+
if ( ! baseData ) {
238+
baseData = {
239+
charset: 'utf-8',
240+
headers: state.opts.headers || DEFAULT_HEADERS,
241+
translations: {
242+
'': {
243+
'': {
244+
msgid: '',
245+
msgstr: [],
246+
},
247+
},
248+
},
249+
};
250+
251+
for ( const key in baseData.headers ) {
252+
baseData.translations[ '' ][ '' ].msgstr.push(
253+
`${ key }: ${ baseData.headers[ key ] };\n`
254+
);
255+
}
256+
257+
// Attempt to exract nplurals from header
258+
const pluralsMatch = ( baseData.headers[ 'plural-forms' ] || '' ).match(
259+
/nplurals\s*=\s*(\d+);/
260+
);
261+
if ( pluralsMatch ) {
262+
nplurals = pluralsMatch[ 1 ];
263+
}
264+
}
265+
266+
// Create empty msgstr or array of empty msgstr by nplurals
267+
if ( translation.msgid_plural ) {
268+
translation.msgstr = Array.from( Array( nplurals ) ).map( () => '' );
269+
} else {
270+
translation.msgstr = '';
271+
}
272+
273+
// Assign file reference comment, ensuring consistent pathname
274+
// reference between Win32 and POSIX
275+
const { filename } = this.file.opts;
276+
const pathname = relative( '.', filename )
277+
.split( sep )
278+
.join( '/' );
279+
translation.comments = {
280+
reference: pathname + ':' + path.node.loc.start.line,
281+
};
282+
283+
// If exists, also assign translator comment
284+
const translator = getTranslatorComment( path );
285+
if ( translator ) {
286+
translation.comments.translator = translator;
287+
}
288+
289+
// Create context grouping for translation if not yet exists
290+
const { msgctxt = '', msgid } = translation;
291+
if ( ! strings[ filename ].hasOwnProperty( msgctxt ) ) {
292+
strings[ filename ][ msgctxt ] = {};
293+
}
294+
295+
strings[ filename ][ msgctxt ][ msgid ] = translation;
296+
},
297+
Program: {
298+
enter() {
299+
strings[ this.file.opts.filename ] = {};
300+
},
301+
exit( path, state ) {
302+
const { filename } = this.file.opts;
303+
if ( isEmpty( strings[ filename ] ) ) {
304+
delete strings[ filename ];
305+
return;
306+
}
307+
308+
// Sort translations by filename for deterministic output
309+
const files = Object.keys( strings ).sort();
310+
311+
// Combine translations from each file grouped by context
312+
const translations = reduce(
313+
files,
314+
( memo, file ) => {
315+
for ( const context in strings[ file ] ) {
316+
// Within the same file, sort translations by line
317+
const sortedTranslations = sortBy(
318+
strings[ file ][ context ],
319+
'comments.reference'
320+
);
321+
322+
forEach( sortedTranslations, translation => {
323+
const { msgctxt = '', msgid } = translation;
324+
if ( ! memo.hasOwnProperty( msgctxt ) ) {
325+
memo[ msgctxt ] = {};
326+
}
327+
328+
// Merge references if translation already exists
329+
if ( isSameTranslation( translation, memo[ msgctxt ][ msgid ] ) ) {
330+
translation.comments.reference = uniq(
331+
[
332+
memo[ msgctxt ][ msgid ].comments.reference,
333+
translation.comments.reference,
334+
]
335+
.join( '\n' )
336+
.split( '\n' )
337+
).join( '\n' );
338+
}
339+
340+
memo[ msgctxt ][ msgid ] = translation;
341+
} );
342+
}
343+
344+
return memo;
345+
},
346+
{}
347+
);
348+
349+
// Merge translations from individual files into headers
350+
const data = merge( {}, baseData, { translations } );
351+
352+
// Ideally we could wait until Babel has finished parsing
353+
// all files or at least asynchronously write, but the
354+
// Babel loader doesn't expose these entry points and async
355+
// write may hit file lock (need queue).
356+
const compiled = po.compile( data );
357+
writeFileSync( state.opts.output || DEFAULT_OUTPUT, compiled );
358+
this.hasPendingWrite = false;
359+
},
360+
},
361+
},
362+
};
363+
};
364+
365+
module.exports.getNodeAsString = getNodeAsString;
366+
module.exports.getTranslatorComment = getTranslatorComment;
367+
module.exports.isValidTranslationKey = isValidTranslationKey;
368+
module.exports.isSameTranslation = isSameTranslation;

0 commit comments

Comments
 (0)