-.l10n.php` * sitting next to a perfectly valid `.mo` silently wins (WordPress core * behavior since 6.5 — `.l10n.php` is preferred over `.mo` when both are * present) and gives zero diagnostic trace. Reproduced by hand: truncating * the `.l10n.php` to 0 bytes next to an untouched valid `.mo` flipped every * admin screen to English; restoring it flipped them back. Not a plugin * defect — this class exists to surface the SYMPTOM, not to fix the cause * (which is core-level infrastructure, out of this plugin's reach), and it * deliberately never asserts what the cause is — only that translated * strings aren't coming through. * * A single canary string was rejected: it false-positives in a partially * translated locale (that one string just happens not to be translated * yet, unrelated to this bug). The canary list is checked as a whole — the * signal only fires when NONE of them translate, which a real, working * (even if incomplete) translation practically never does. * * This class translates NOTHING itself (fixed 2026-09-02 — see below). * Every string it needs — the canary list's translations and its own * notice/button/error text — is supplied already-translated by the * product at construction. Each product's OWN file makes its own `__()` * calls with ITS OWN literal text domain, which `wp i18n make-pot` can * see and extract; this shared file never calls `__()`/`translate()` * itself, so it never needs a domain of its own. This is the standard * pattern for a shared library used from more than one text domain — the * earlier version passed a dynamic `$text_domain` into `translate()` here * instead, which `make-pot`'s static extractor cannot resolve, so every * string this class ever displayed (the canary comparisons, the notice * body, the dismiss button, the permission-denied message) was silently * invisible to translation tooling in both ABDS SEO and ABDS Connect — * and, it turned out, in ABDS_Product_Updater_V2's identical pattern too, * masked there only because its own notice text happened to already be * Ukrainian. Caught before this shipped rather than after: fixing it * post-release would have meant every string changing msgid a second * time, discarding whatever translation work had already landed on the * first (broken) one. * * Shared verbatim between ABDS SEO and ABDS Connect (see * abds-infra/bin/check-shared-files.sh) — parameterized by slug, version * and capability rather than hardcoding either product, the same shape as * ABDS_Product_Updater_V2. Registers hooks directly (no dependency on either * product's own Loader class), for the same reason. * * VERSIONING RULE (2026-09-07, preventive — see class-abds-product-updater.php's * own docblock, in the Pro add-ons, for the incident this rule comes from; * the same class_exists()-guarded sharing pattern applies here, between the * two FREE plugins instead of the two Pro add-ons). This class is NOT * currently version-suffixed — its constructor signature has been stable * since this file was added (2026-09-05) and has not yet been battle-tested * against a real change, unlike ABDS_License_Client_V2's two prior * additive changes. That is not a guarantee it stays that way. The FIRST * incompatible change to its public contract (constructor signature, or any * method the OTHER free plugin's code calls directly) must rename the * class to a version suffix (ABDS_Translation_Canary_V2) IN THE SAME * RELEASE as the change, in both free plugins at once, before either ships. * * @package ABDS_Translation_Canary */ if ( ! defined( 'ABSPATH' ) ) { exit; } if ( ! class_exists( 'ABDS_Translation_Canary' ) ) : // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- deliberately ABDS_, not ABDS_SEO_: shared across every ABDS product, matching ABDS_License_Client_V2's own naming for the same reason. /** * Detects and notifies about a broken local translation. */ class ABDS_Translation_Canary { // phpcs:enable /** * How long a computed "broken or not" answer is trusted before * re-checking. Translation state changes rarely (a translation * update, a plugin update); the check itself is a handful of * in-memory string lookups (no HTTP calls, unlike this plugin's * sitemap probe) so this is about not re-deriving the same answer * needlessly, not about protecting against a slow operation. */ const RESULT_TRANSIENT_TTL = 12 * HOUR_IN_SECONDS; /** * This instance's canary check, as `source string => that same * string's real, already-resolved translation` — built by the * product as `array( 'Settings' => __( 'Settings', 'abds-seo' ), ... )`, * a literal-domain call in the PRODUCT's own file (see this * class's own docblock for why that matters). Deliberately not a * class constant: ABDS SEO's own list ("Settings", "SEO title", * "Focus keyword", etc.) is entirely ABDS-SEO-specific text; ABDS * Connect doesn't ship a single one of those strings, so a shared * list would report "broken" unconditionally on every non-English * Connect install — the strings would never translate because * they'd never exist in Connect's own .pot in the first place. * Each product supplies its own list at construction (see each * product's own bootstrap for where its list is defined, and its * own TranslationCanaryPotTest-style test for keeping that list * honest against its own generated .pot). * * Selection rule for every product's own list (agreed with the * product owner 2026-09-02, revised 2026-09-03 after a real * false-negative — governs this and every future list; do not * restate this rule per product, link back here instead): * 1. EVERY entry is a full sentence, roughly six words or more — * not a term, not a menu-item name, not a bare word. This * replaces an earlier, weaker rule ("at least half the list"). * Reason for the tightening, found live 2026-09-03: ABDS SEO's * original list included "SEO title", "Meta description", * "Focus keyword" — short SEO terminology any SEO add-on in * the same product family is likely to use somewhere in its * own UI too. On a real site running ABDS SEO + ABDS SEO Pro * together, a genuine, confirmed translation-loading bug (Pro * shipping a translation file under the free plugin's own text * domain, discussed at length elsewhere in this codebase) left * 5 of 8 canary strings genuinely untranslated — but those 3 * short terms still happened to translate anyway, because * Pro's OWN narrower translation file happened to separately * contain the same terms with matching Ukrainian text. 5 of 8 * failing isn't unanimous, so the canary never fired: a real, * live, confirmed bug it exists to catch went undetected. * A short technical term is exactly the shape of text two * related products are likely to phrase identically by * coincidence; a full sentence — six-plus specific words in a * specific order — realistically is not, even when the two * products cover overlapping subject matter. Confirmed by the * same live reproduction after this list was rebuilt to this * rule: see each product's own TranslationCanaryPotTest-style * test file and bootstrap for the rebuilt list and the repeat * verification. * 2. No entry names the product or a brand — a brand name inside * the msgid isn't what a translator actually translates, and * it risks a coincidental identical rendering unrelated to * whether the surrounding UI text is really translated. * 3. Prefer strings a translator was guaranteed to have actually * seen and translated — permanently visible interface text * over a rare, conditional error message; a string that only * renders after a specific failure may simply never have been * reached during translation. * 4. Every product keeps its own POT-completeness test (see this * class's own docblock) asserting each of its strings is * still a real msgid in that product's own generated .pot. * * If a product genuinely cannot find eight sentences meeting rule * 1, the list shrinks below eight rather than admitting a * shorter, weaker entry — the word-count floor is not negotiable * per product. * * @var array */ protected array $canary_translations; /** * This instance's own display text, already translated by the * product (see this class's own docblock). Required keys: * - notice_message: the admin_notices body (states only the * fact that translations aren't applying and * one thing to try, never a claimed cause). * - dismiss_label: the dismiss button's label. * - permission_denied: handle_dismiss()'s wp_die() message for a * capability check failing on a forged/stale * request. * * @var array{notice_message:string,dismiss_label:string,permission_denied:string} */ protected array $strings; /** * Plugin slug (e.g. 'abds-seo'), used to find the expected .mo * path and to namespace this class's own options/transient. * * @var string */ protected string $slug; /** * Currently running plugin version — an acknowledged notice is * re-shown after a version bump, the same versioned-acknowledgment * shape ABDS_SEO_Noindex_Activation already uses. * * @var string */ protected string $version; /** * Capability gating both seeing the notice and dismissing it. * * @var string */ protected string $capability; /** * Constructor. * * @param string $slug Plugin slug (e.g. 'abds-seo'). * @param string $version Currently running plugin version. * @param array $canary_translations This product's own canary check, as * `source string => its real translation` * (see $canary_translations's own docblock). * @param array $strings This instance's own already-translated * display text — see $strings's own docblock * for the required keys. * @param string $capability Capability gating the notice; defaults to * 'activate_plugins' since this class may be * shared with a product that has no bespoke * capability of its own. */ public function __construct( string $slug, string $version, array $canary_translations, array $strings, string $capability = 'activate_plugins' ) { $this->slug = $slug; $this->version = $version; $this->canary_translations = $canary_translations; $this->strings = $strings; $this->capability = $capability; } /** * Registers this class's hooks directly against WordPress. * upgrader_process_complete fires for a language-pack update AND a * plugin update alike — either is worth forcing a fresh check on, * rather than waiting out RESULT_TRANSIENT_TTL. */ public function register_hooks(): void { add_action( 'admin_notices', array( $this, 'maybe_notice' ) ); add_action( 'admin_post_' . $this->dismiss_action(), array( $this, 'handle_dismiss' ) ); add_action( 'upgrader_process_complete', array( $this, 'invalidate_cache' ) ); } /** * Whether the current site/locale looks like a broken local * translation: not English, a .mo for this slug+locale genuinely * exists on disk (so a translation package really is installed — * nothing to detect otherwise), and none of canary_translations's * entries actually translated (source === the resolved value the * product handed in). Cached; see RESULT_TRANSIENT_TTL. * * @return bool */ public function is_translation_broken(): bool { $locale = determine_locale(); if ( 0 === strpos( $locale, 'en' ) ) { return false; } $mo_path = WP_LANG_DIR . '/plugins/' . $this->slug . '-' . $locale . '.mo'; if ( ! file_exists( $mo_path ) ) { return false; } $cached = get_transient( $this->result_transient_key() ); if ( false !== $cached ) { return '1' === $cached; } $broken = true; foreach ( $this->canary_translations as $source => $translated ) { if ( $translated !== $source ) { $broken = false; break; } } set_transient( $this->result_transient_key(), $broken ? '1' : '0', self::RESULT_TRANSIENT_TTL ); return $broken; } /** * Admin_notices — shows the notice when broken and not yet * acknowledged for this version. Wording states only the fact * (translations installed but not applying) and one thing to try, * never a claimed cause — this plugin doesn't know the cause, and * saying so isn't its place. The wording itself is the product's * own (see $strings's docblock); this method only assembles and * escapes it. */ public function maybe_notice(): void { if ( ! current_user_can( $this->capability ) ) { return; } if ( get_option( $this->acknowledged_option() ) === $this->version ) { return; } if ( ! $this->is_translation_broken() ) { return; } printf( '

%1$s

%2$s

', esc_html( $this->strings['notice_message'] ), sprintf( '
%2$s
', esc_url( admin_url( 'admin-post.php' ) ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_nonce_field() with $echo=false returns safe, self-generated HTML (a hidden input), the standard way to inline it into a larger printf-built string. wp_nonce_field( $this->dismiss_action(), '_wpnonce', true, false ), esc_attr( $this->dismiss_action() ), esc_html( $this->strings['dismiss_label'] ) ) ); } /** * Admin_post_{dismiss_action} — records this version as * acknowledged and redirects back. Real, persisted dismissal (not * a client-side is-dismissible hide), same reasoning as * ABDS_SEO_Noindex_Activation_Notice: this is a meaningful, * version-gated fact, not a "stop cluttering my screen" preference. */ public function handle_dismiss(): void { if ( ! current_user_can( $this->capability ) ) { wp_die( esc_html( $this->strings['permission_denied'] ) ); } check_admin_referer( $this->dismiss_action() ); update_option( $this->acknowledged_option(), $this->version ); $referer = wp_get_referer(); wp_safe_redirect( $referer ? $referer : admin_url() ); exit; } /** * Drops the cached result — a translation update or this plugin's * own update are the two events worth re-checking on, wired up by * each product's own bootstrap (upgrader_process_complete covers * both: a language-pack update and a plugin update alike fire it). */ public function invalidate_cache(): void { delete_transient( $this->result_transient_key() ); } /** * Namespaced admin_post action name. * * @return string */ protected function dismiss_action(): string { return 'abds_translation_canary_dismiss_' . str_replace( '-', '_', $this->slug ); } /** * Namespaced option name storing the acknowledged version. * * @return string */ protected function acknowledged_option(): string { return 'abds_translation_canary_ack_' . str_replace( '-', '_', $this->slug ); } /** * Namespaced transient key caching the computed result. * * @return string */ protected function result_transient_key(): string { return 'abds_translation_canary_result_' . str_replace( '-', '_', $this->slug ); } } endif;