Back

88.37% Statements 76/86
85.56% Branches 83/97
100% Functions 8/8
88.37% Lines 76/86

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296                                              2x               2x                       38x                                 3x                             12x 12x 4x   8x       8x 8x       8x 8x   8x     8x   8x                         8x   8x         1x 1x           7x 6x         1x 1x                                                     6x 6x   6x 49x 17x   32x       3x 3x   29x 12x       6x 3x   3x       3x 3x 3x       5x 5x 2x   3x       1x                           8x 8x         8x             1x   7x           7x                             677x 677x 19x       677x   677x 677x       677x 47x 20x   27x 2x   25x     630x       630x 605x 1x   604x       25x 25x 21x   4x 4x 1x   3x 3x     2x  
/**
 * Callee resolution for gettext-style calls, shared by the plugin itself and
 * by tools that need to recognise exactly the same calls (e.g. the wp-build
 * i18n stub generator).
 *
 * A gettext call reaches the AST in one of three shapes: a bare identifier
 * (`__( 'x' )`, possibly import-aliased), a member call (`i18n.__( 'x' )`), or
 * esbuild's interop sequence expression (`(0, import_i18n.__)( 'x' )`).
 *
 * Matching those purely by name is fine for hand-written source, but not for
 * whole bundles: a `cache.__( key )` inside a bundled dependency looks
 * identical. `requireI18nSource` turns on a provenance check that refuses to
 * treat a call as gettext when its callee provably comes from somewhere other
 * than the i18n module.
 *
 * The check is deliberately "reject what is proven foreign" rather than
 * "accept only what is proven i18n". A callee whose origin can't be
 * determined is still treated as gettext, so an unrecognised bundler shape
 * degrades to the old behaviour — noisier — instead of silently leaving a
 * whole bundle unstamped and its UI in English.
 */
 
/** How many binding hops the provenance trace follows before reporting "unknown". */
const MAX_TRACE_DEPTH = 6;
 
/**
 * Memoized trace results, keyed by binding and then by i18n module name.
 * Bindings are per-file objects, so entries fall out with the file's AST.
 *
 * @type {WeakMap<object, Map<string, boolean|null>>}
 */
const traceCache = new WeakMap();
 
/**
 * Whether a node reads a property named `i18n` — `wp.i18n`,
 * `window.wp.i18n`: the shape bundlers emit when `@wordpress/i18n` is
 * externalized to a global.
 *
 * @param {object} t    - Babel types.
 * @param {object} node - Node to test.
 * @return {boolean} Whether the node reads an `i18n` property.
 */
function isI18nPropertyAccess( t, node ) {
	return (
		( t.isMemberExpression( node ) || t.isOptionalMemberExpression( node ) ) &&
		! node.computed &&
		t.isIdentifier( node.property, { name: 'i18n' } )
	);
}
 
/**
 * Whether a string names the i18n module. Bundlers prefix their synthetic
 * module keys (esbuild writes `package-external:@wordpress/i18n`), so a
 * suffix match after a colon counts too.
 *
 * @param {string} value      - String literal value.
 * @param {string} i18nModule - Module name to match.
 * @return {boolean} Whether the string names the module.
 */
function namesI18nModule( value, i18nModule ) {
	return value === i18nModule || value.endsWith( `:${ i18nModule }` );
}
 
/**
 * Trace an identifier back to the i18n module through its binding.
 *
 * @param {object}     t             - Babel types.
 * @param {object}     scopePath     - Path whose scope resolves `name`.
 * @param {string}     name          - Identifier name.
 * @param {string}     i18nModule    - Module name to trace to.
 * @param {object}     state         - Trace state: `{ depth, seen }`.
 * @param {false|null} unboundResult - What an unresolvable name means here.
 * @return {boolean|null} `true` when it resolves to the i18n module, `false` when it resolves elsewhere, `null` when unknown.
 */
function traceIdentifier( t, scopePath, name, i18nModule, state, unboundResult ) {
	const binding = scopePath.scope.getBinding( name );
	if ( ! binding ) {
		return unboundResult;
	}
	Iif ( state.seen.has( binding ) || state.depth > MAX_TRACE_DEPTH ) {
		return null;
	}
 
	const cached = traceCache.get( binding );
	Iif ( cached?.has( i18nModule ) ) {
		return cached.get( i18nModule );
	}
 
	state.seen.add( binding );
	const result = traceBinding( t, binding, i18nModule, state );
 
	Iif ( cached ) {
		cached.set( i18nModule, result );
	} else {
		traceCache.set( binding, new Map( [ [ i18nModule, result ] ] ) );
	}
	return result;
}
 
/**
 * Classify what a binding was bound to.
 *
 * @param {object} t          - Babel types.
 * @param {object} binding    - Babel binding.
 * @param {string} i18nModule - Module name to trace to.
 * @param {object} state      - Trace state: `{ depth, seen }`.
 * @return {boolean|null} Tri-state as in `traceIdentifier()`.
 */
function traceBinding( t, binding, i18nModule, state ) {
	const bindingPath = binding.path;
 
	if (
		bindingPath.isImportSpecifier() ||
		bindingPath.isImportDefaultSpecifier() ||
		bindingPath.isImportNamespaceSpecifier()
	) {
		const importDecl = bindingPath.parentPath;
		return (
			importDecl.isImportDeclaration() &&
			namesI18nModule( importDecl.node.source.value, i18nModule )
		);
	}
 
	if ( bindingPath.isVariableDeclarator() && bindingPath.node.init ) {
		return traceInitializer( t, bindingPath, bindingPath.node.init, i18nModule, state );
	}
 
	// A locally declared function or class is definitively not an import of
	// the i18n module.
	Eif ( bindingPath.isFunctionDeclaration() || bindingPath.isClassDeclaration() ) {
		return false;
	}
 
	// Parameters, catch clauses, uninitialized declarations: unknown.
	return null;
}
 
/**
 * Look for the i18n module inside a variable's initializer, following the
 * identifiers it references.
 *
 * Bundlers wrap the module several hops deep — esbuild binds
 * `var import_i18n = __toESM( require_i18n(), 1 )`, where `require_i18n` is a
 * `__commonJS` wrapper assigning `module.exports = window.wp.i18n` — and
 * minification renames every identifier along the way. So instead of matching
 * a fixed shape, this scans the initializer (and, a hop at a time, whatever it
 * references) for either the module's name as a string or a read of an `i18n`
 * property. Both survive minification.
 *
 * @param {object} t          - Babel types.
 * @param {object} scopePath  - Path whose scope resolves identifiers in `init`.
 * @param {object} init       - Initializer node.
 * @param {string} i18nModule - Module name to trace to.
 * @param {object} state      - Trace state: `{ depth, seen }`.
 * @return {boolean|null} Tri-state as in `traceIdentifier()`.
 */
function traceInitializer( t, scopePath, init, i18nModule, state ) {
	const identifiers = [];
	let found = false;
 
	t.traverseFast( init, node => {
		if ( found ) {
			return;
		}
		if (
			( t.isStringLiteral( node ) && namesI18nModule( node.value, i18nModule ) ) ||
			isI18nPropertyAccess( t, node )
		) {
			found = true;
			return;
		}
		if ( t.isIdentifier( node ) ) {
			identifiers.push( node.name );
		}
	} );
 
	if ( found ) {
		return true;
	}
	Iif ( state.depth >= MAX_TRACE_DEPTH ) {
		return null;
	}
 
	const nested = { depth: state.depth + 1, seen: state.seen };
	let truncated = false;
	for ( const name of identifiers ) {
		// Names with no binding here are globals (`Map`, `Symbol`, …). A
		// global i18n would have been caught by the property-access marker
		// above, so they are not evidence either way — hence `false`.
		const result = traceIdentifier( t, scopePath, name, i18nModule, nested, false );
		if ( result === true ) {
			return true;
		}
		Iif ( result === null ) {
			truncated = true;
		}
	}
	return truncated ? null : false;
}
 
/**
 * Whether an expression provably resolves to something other than the i18n
 * module.
 *
 * @param {object} t          - Babel types.
 * @param {object} path       - Path providing the scope for `node`.
 * @param {object} node       - Expression to classify (a callee, or a callee's object).
 * @param {string} i18nModule - Module name to trace to.
 * @return {boolean} Whether the expression is proven foreign.
 */
function isForeignSource( t, path, node, i18nModule ) {
	const state = { depth: 0, seen: new Set() };
	Iif ( isI18nPropertyAccess( t, node ) ) {
		return false;
	}
	// A value written out inline is never the i18n module: rolldown inlines a
	// single-use `const cache = { __: … }` into `{ __: … }.__( key )`.
	if (
		t.isObjectExpression( node ) ||
		t.isArrayExpression( node ) ||
		t.isFunction( node ) ||
		t.isClass( node ) ||
		t.isLiteral( node )
	) {
		return true;
	}
	Iif ( ! t.isIdentifier( node ) ) {
		// An unrecognised callee shape: unknown, not foreign.
		return false;
	}
	// An unbound name at the top of the chain is unknown, not foreign: hand-
	// written snippets and pre-bundling sources call through free variables.
	return traceIdentifier( t, path, node.name, i18nModule, state, null ) === false;
}
 
/**
 * Resolve the gettext function a call expression invokes.
 *
 * @param {object}  t                           - Babel types (`babel.types`).
 * @param {object}  path                        - Path of the `CallExpression` / `OptionalCallExpression`.
 * @param {object}  options                     - Options.
 * @param {object}  options.functions           - Map of gettext function name to domain argument index.
 * @param {string}  options.i18nModule          - Module the gettext functions are expected to come from.
 * @param {boolean} [options.requireI18nSource] - Reject callees that provably come from another module.
 * @return {string|null} The gettext function name, or `null` when the call is not one.
 */
function resolveGettextCallee( t, path, { functions, i18nModule, requireI18nSource = false } ) {
	let callee = path.node.callee;
	if ( t.isSequenceExpression( callee ) ) {
		callee = callee.expressions[ callee.expressions.length - 1 ];
	}
 
	const isMember =
		( t.isMemberExpression( callee ) || t.isOptionalMemberExpression( callee ) ) &&
		! callee.computed;
	const calleeName = isMember ? callee.property.name : callee.name;
	Iif ( typeof calleeName !== 'string' ) {
		return null;
	}
 
	if ( isMember ) {
		if ( ! Object.hasOwn( functions, calleeName ) ) {
			return null;
		}
		if ( requireI18nSource && isForeignSource( t, path, callee.object, i18nModule ) ) {
			return null;
		}
		return calleeName;
	}
 
	Iif ( ! t.isIdentifier( callee ) ) {
		return null;
	}
 
	if ( Object.hasOwn( functions, calleeName ) ) {
		if ( requireI18nSource && isForeignSource( t, path, callee, i18nModule ) ) {
			return null;
		}
		return calleeName;
	}
 
	// An import alias: `import { __ as __alias } from '@wordpress/i18n'`.
	const bindingPath = path.scope.getBinding( calleeName )?.path;
	if ( ! bindingPath || ! bindingPath.isImportSpecifier() ) {
		return null;
	}
	const importDecl = bindingPath.parentPath;
	if ( ! importDecl.isImportDeclaration() || importDecl.node.source.value !== i18nModule ) {
		return null;
	}
	const imported = bindingPath.node.imported.name;
	return Object.hasOwn( functions, imported ) ? imported : null;
}
 
module.exports = { resolveGettextCallee, MAX_TRACE_DEPTH };