wp-i18n-audit — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited wp-i18n-audit (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
A focused review of translation correctness in WP plugin or theme PHP. Catches the boring mistakes that cause .pot extraction to break, translators to ship wrong files, or strings to silently fail to translate at runtime.
Trigger when:
__(, _e(, _n(, _x(, _nx(, _ex(, esc_html__(, esc_html_e(, esc_attr__(, esc_attr_e(, translate(, load_plugin_textdomain, load_theme_textdomain, Text Domain:.Work through the checks below. For each finding, produce:
Do NOT silently rewrite. Produce a report; only edit on user request.
Before any other check, find the project's declared text-domain:
style.css (theme).Text Domain: <slug>.slug** — since WP 4.6 wp.org-distributed plugins auto-load translations using the folder slug as the domain, and a missing header is not by itself a runtime breakage. Severity for the missing header alone:
slug as the domain — runtime works.
recommended for clarity and tooling).
different domain than the folder slug — runtime translation will not work.
If the codebase contains multiple plugins/themes, repeat per project — do NOT mix domains across folders.
Every translation call must pass the expected domain as the last argument.
// WRONG — wrong domain
__( 'Save', 'some-other-plugin' )
// WRONG — missing domain (defaults to WP core, broken extraction)
__( 'Save' )
// RIGHT
__( 'Save', 'myplugin' )Flag any call where the domain literal does not match the project domain. HIGH if the wrong domain is used (string lands in another project's .pot); MEDIUM if missing.
// WRONG — wp i18n tools cannot extract this
$d = 'myplugin';
__( 'Save', $d );
__( 'Save', MY_TEXTDOMAIN );Tools like wp i18n make-pot parse statically. The domain MUST be a string literal. Constants and variables are skipped. HIGH.
// WRONG — extractor cannot follow
__( $message, 'myplugin' )
__( "Hello $name", 'myplugin' ) // double quotes with interpolation
__( 'Hello ' . $name, 'myplugin' ) // concatenationThe first argument must be a static string literal. For dynamic content use sprintf with placeholders (check 5). HIGH — string never appears in .pot.
If the translated string is echoed into HTML, escape AT translation time using the right helper:
// WRONG
echo __( 'Save', 'myplugin' );
// RIGHT — HTML body
echo esc_html__( 'Save', 'myplugin' );
esc_html_e( 'Save', 'myplugin' );
// RIGHT — HTML attribute
echo '<button title="' . esc_attr__( 'Save', 'myplugin' ) . '">';Map:
__() → builds string only, no escape (use when passing to a function that escapes later, or in non-HTML contexts)._e() → echoes unescaped (rarely correct in HTML — flag as MEDIUM unless context is non-HTML or escape is applied around it).esc_html__ / esc_html_e → for HTML body.esc_attr__ / esc_attr_e → for HTML attributes.href/src, translate with __() then wrap with esc_url().// WRONG — translators cannot reorder, can break grammar
echo __( 'Hello, ', 'myplugin' ) . $name . __( '!', 'myplugin' );
// RIGHT
printf(
/* translators: %s: user display name */
esc_html__( 'Hello, %s!', 'myplugin' ),
esc_html( $name )
);Rules:
%s (string), %d (int), %1$s %2$s for ordered.%1$s, %2$s) so translators can reorder./* translators: %1$s: ..., %2$s: ... */ comment immediately above the call. wp i18n tools surface this in the .pot.esc_html( $name )) — the translation helper escapes the template, not the inserted value._n and _nx// WRONG — English-centric, breaks in languages with multiple plural forms
echo $count . ' items';
printf( __( '%d items', 'myplugin' ), $count );
// RIGHT
printf(
/* translators: %s: number of items */
esc_html( _n( '%s item', '%s items', $count, 'myplugin' ) ),
number_format_i18n( $count )
);Use _n for plurals, _nx when context is needed. Flag any if ( $n === 1 ) ... else ... pattern around translated strings — that's English plural logic, doesn't generalize.
_x and _exWhen a single English word has multiple meanings (e.g. "Post" = noun vs. verb), use _x to disambiguate for translators:
$label_noun = _x( 'Post', 'noun: a blog post', 'myplugin' );
$label_verb = _x( 'Post', 'verb: to publish', 'myplugin' );Flag short, ambiguous strings ('Post', 'View', 'Order', 'Address', 'Read') using __ without context — LOW unless the project clearly mixes meanings, then MEDIUM.
load_plugin_textdomain / load_theme_textdomainPlugins distributed via wp.org since WP 4.6 have translations auto-loaded — an explicit load_plugin_textdomain() call is recommended for self-hosted .mo files but not strictly required for wp.org-distributed plugins. Do not treat its absence as a HIGH finding without first establishing whether the plugin is shipped via wp.org.
When you do call it, hook on init (recommended) — NOT plugins_loaded. Since WP 6.7, calling load_plugin_textdomain() before init triggers a _doing_it_wrong notice because just-in-time translation loading runs at init. Themes hook on after_setup_theme.
add_action( 'init', function () {
load_plugin_textdomain(
'myplugin',
false,
dirname( plugin_basename( __FILE__ ) ) . '/languages/'
);
} );Check:
HIGH mismatch.
init or later — not plugins_loaded. MEDIUM if tooearly (triggers _doing_it_wrong on WP 6.7+).
missing on a self-hosted plugin; LOW for wp.org plugins where auto-loading covers it.
Plugin header (main file) or theme header (style.css) must declare Text Domain and ideally Domain Path:
Plugin Name: My Plugin
Text Domain: myplugin
Domain Path: /languagesSeverity of a missing Text Domain header depends on whether strings still resolve at runtime — see the rule in Determine the canonical text-domain first at the top of this skill. Missing Domain Path is LOW if the directory is the default /languages.
If the plugin localizes strings to JS via wp_set_script_translations:
wp_set_script_translations( 'myplugin-script', 'myplugin', plugin_dir_path( __FILE__ ) . 'languages' );In JS:
import { __, _n, sprintf } from '@wordpress/i18n';
const label = __( 'Save', 'myplugin' );Flag mismatched domain between PHP and JS calls. Out of scope: deep JS audit (different skill).
__(), missing Text Domain header._e in HTML, missing translator comment on sprintf, concatenation across translated strings, load_plugin_textdomain on the wrong hook, plural handled with English if.Domain Path header, ambiguous short strings without _x context, missing load_plugin_textdomain on a wp.org-distributed plugin.# i18n audit: <plugin/theme name>
Declared text-domain: <slug>
Header location: <file>:<line>
Date: <YYYY-MM-DD>
## HIGH
1. <file>:<line> — <issue>
<code>
Fix: <code>
## MEDIUM
...
## LOW
...
## Out of scope
- JS string audit (run separately if needed).
- .po / .mo file content (this skill only audits source code).wp-security-audit, wp-security-secrets, and this skill in sequence before submission..po / .mo translations (linguistic review).number_format_i18n reminder.wp i18n make-pot: https://developer.wordpress.org/cli/commands/i18n/make-pot/~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.