Skip to content

feat: integrator string overrides + per-user locale (closes #66, #140)#143

Open
zackkatz wants to merge 8 commits into
developfrom
feature/i18n-strings-prototype
Open

feat: integrator string overrides + per-user locale (closes #66, #140)#143
zackkatz wants to merge 8 commits into
developfrom
feature/i18n-strings-prototype

Conversation

@zackkatz

Copy link
Copy Markdown
Contributor

Summary

Adds two long-standing i18n capabilities to the SDK:

  • Integrator string overrides (closes Add strings to configuration #66) — a new Strings class exposes every user-facing SDK string as a stable constant. Integrators can override any string verbatim, with an empty value, or with a closure (for context-dependent variants) via the strings Config key. Strauss-safe: each prefixed SDK image gets its own filter namespace, so overrides never collide across plugins that ship the SDK.
  • Per-user locale for the created support user (closes Add ability to set language for the created support user #140) — new support_user/locale config + trustedlogin/{ns}/support_user/locale filter. Locale is written through wp_insert_user's locale arg (WP 4.7+) and defensively re-asserted via update_user_meta to survive wp_insert_user ignoring the arg in some scenarios.

The 117 user-facing strings in src/Form.php, src/Remote.php, src/SupportUser.php, src/Admin.php, etc. were migrated to route through Strings::get(Strings::KEY, __('English', 'trustedlogin')). The literal __() is the anchor wp i18n make-pot extracts; Strings::get() then re-translates against whichever textdomain the integrator routed via Strings::load_translations().

Why this shape

  • Strings::get() is static — no per-class plumbing. Call sites read like Strings::get(Strings::SECURED_BY_TRUSTEDLOGIN, __( 'Secured by TrustedLogin', 'trustedlogin' )).
  • Closure overrides are safetry { … } catch ( \Exception $e ) around every integrator callback + filter, routed through the SDK's own Logging surface. PHP 5.3 target rules out \Throwable, so \Error from integrator code is not caught (accepted trade-off).
  • sprintf "too few args" guardStrings::count_placeholders() checks the resolved string against the registry's declared placeholder budget. If a closure/filter inflates the placeholder count, we fall back to the SDK default rather than letting a ValueError reach the caller's sprintf.
  • Config::validate_strings() uses a sentinel-based vsprintf test (placeholders_safe()) to reject overrides whose placeholder layout doesn't match the SDK default — so an override of 'Extend %1$s Access for %2$s' with 'Extend %s Access' is rejected at config time, not at render time.

Test coverage

  • 216 tests / 759 assertions, all passing.
  • tests/test-strings.php — Strings API (init/reset/registry/known_keys/get/resolve/count_placeholders), every override shape (null / empty / string / closure), placeholders_safe() edge cases.
  • tests/test-strings-translation.php — translation routing against the integrator's textdomain, switch-locale behaviour, pre-init deferral.
  • tests/test-support-user-locale.php — explicit locale, filter, fallback to site locale, defensive re-assert.
  • tests/test-translator-comments-extracted.phpmeta-test that runs wp i18n make-pot and verifies every // translators: comment in src/ reaches the resulting POT. Catches the WP-CLI extractor's proximity rule at CI time.

Quality gates

  • PHPCS (.phpcs.xml.dist, testVersion: 5.3-) — clean
  • PHPStan (phpVersion: 70400) — clean
  • PHPUnit (multisite) — 216 / 216

Notes for review

  • AGENTS.md has a new Comment Discipline section documenting the PHP 5.3 runtime target and the rule against meta-narrative / task-reference comments.
  • tests/test-translator-comments-extracted.php is a meta-test, not a unit test — it shells out to wp i18n make-pot and runs only in the tests-cli container. It should be kept in CI.

Closes #66
Closes #140

Test plan

  • PHPCS clean (composer lint)
  • PHPStan clean (composer phpstan)
  • PHPUnit clean (npx wp-env run tests-cli --env-cwd=wp-content/plugins/client composer test)
  • Manual: integrator override of a non-placeholder string (e.g. Strings::SECURED_BY_TRUSTEDLOGIN) renders verbatim
  • Manual: integrator override of a placeholder string with wrong arg count is rejected by Config::validate_strings() at construction
  • Manual: support_user/locale set to de_DE produces a support user whose admin UI is German
  • Manual: change_locale on a running site reloads the SDK's .mo correctly

zackkatz added 8 commits May 13, 2026 01:44
Implements the scoped i18n architecture (issues #66 and #140) in a
shape designed to look like idiomatic WordPress code:

- src/Strings.php: registry of overrideable string keys (class
  constants), Strings::get($key, $default, $context = []) accessor,
  Strings::load_translations($integrator_textdomain) opt-in entry
  point. Runtime textdomain routing means each Strauss-prefixed SDK
  image loads under its own textdomain — no cross-plugin collision.

- src/Config.php: Config::validate_strings() walks the optional
  `strings` array and discards malformed overrides individually.
  Placeholder-safety check uses a behavioral sentinel test against
  vsprintf (not a regex over sprintf grammar) so it catches "missing
  placeholders" AND "extra placeholders" AND "wrong conversion type"
  in one shot. Bad entries log a warning; valid entries are kept.

- src/Form.php: 5 highest-visibility user-facing strings migrated
  to the new accessor as proof points. The literal __() calls stay
  at the call site so `wp i18n make-pot` extraction still works.

- src/SupportUser.php: support_user/locale config setting +
  trustedlogin/{ns}/support_user/locale filter. Format validation
  via regex (variant nested inside region group so de_de doesn't
  parse as "de + variant"). Locale lands in wp_insert_user($args)
  so wp_new_user_notification_email fires in the right language;
  defensive re-assert covers wp_pre_insert_user_data filters that
  strip unknown fields.

Tests (49 new, all 179 still green; PHPStan clean):

- test-strings.php: override resolution, placeholder enforcement
  (positive + negative cases including object overrides, escaped
  %%, missing/extra placeholders), closure invocation with
  $context positional args, runtime filter, unknown-key drop.

- test-strings-translation.php: load_translations sets runtime
  textdomain, lookups route through it, override preempts
  translation, closure override can call __() against integrator
  textdomain, runtime filter sees user-locale context for
  context-aware overrides, change_locale callback runs without
  crashing when no .mo present.

- test-support-user-locale.php: configured locale lands on the
  new user, get_user_locale() returns it, switch_to_user_locale()
  activates it, locale persists across simulated logins,
  defensive re-assert kicks in when wp_pre_insert_user_data strips
  the field, filter can override or clear, format validation
  rejects shell-injection / path-traversal / wrong-case-region
  attempts, accepts pt_PT_ao90 / ckb / ckb_IQ.

This is a prototype on a feature branch — NOT a release. Open
questions:

1. Should this ship as Approach A (English-only SDK with optional
   integrator overrides; SDK ships no .mo files yet) or Approach B
   (SDK builds + ships its own .mo files for a curated locale set)?
   The expert review pushed for B given the SDK's value prop of
   helping support agents serve non-English customers. This commit
   leaves both paths open: Strings::load_translations() works
   today as a routing primitive; shipping .mo files is a separate
   CI + translation-distribution decision.

2. Should Strings::get() be split into get() + get_plural() for
   _n() coverage, or keep one method with a closure-shape override
   for plurals? Current shape relies on closures, which delegate
   plural resolution to the integrator's own gettext.
All 117 unique user-facing strings across the SDK now route through
$this->strings->get(KEY, __('English', 'trustedlogin')). The __()
literals stay in place at every call site so wp i18n make-pot
extraction continues to work; runtime override resolution happens
inside the accessor.

Coverage:

  Admin.php:           4 strings
  Ajax.php:            2 strings
  Client.php:          3 strings
  Encryption.php:      2 strings
  Endpoint.php:        2 strings
  Form.php:           89 strings  (the bulk — consent screen, headers,
                                   error banners, success messages,
                                   debug panel, brute-force lockdown)
  Remote.php:          5 strings  (handle_response error paths only —
                                   see "static methods" below)
  SecurityChecks.php:  2 strings
  SiteAccess.php:      2 strings
  SupportRole.php:     1 string
  SupportUser.php:     2 strings

Per-class plumbing: each class that uses translations now has a
private `$strings` property initialized in the constructor from
`new Strings( $config )`. Pattern is consistent — Form, Admin, Ajax,
Client, Encryption, Endpoint, Remote, SecurityChecks, SiteAccess,
SupportRole, SupportUser all gained the property + assignment.

Strings.php now registers 117 constants + matching registry entries,
each declaring its sprintf placeholder count. Config::validate_strings
checks integrator overrides against this schema using a sentinel-
based behavioral vsprintf test (catches missing/extra/wrong-type
placeholders in one shot, no regex-over-sprintf-grammar maintenance
burden).

Static methods deliberately NOT migrated. Remote::check_response_code
and Remote::body_looks_like_html are static utility methods — they
can't reference $this->strings. Their 9 strings remain as plain
esc_html__() literals. They're still POT-extractable and translatable
via the SDK's own .mo files (Approach B); they just aren't
overridable through the strings config. Real users will rarely want
to override these anyway (they're HTTP-status-specific error
messages mostly visible in debug logs).

Constant names are auto-generated from the first 5-6 words of each
English string, with a 6-char hex suffix on collisions. They're
SemVer-stable: renames are breaking. A handful (USER_NOT_CREATED…,
VENDOR_HAS_SITE_ACCESS_THAT, SUPPORT_ACCESS_VENDOR_*) were renamed
by hand to either avoid PHP's "no leading digit" rule or to
disambiguate 60-char-truncated registry-comment collisions.

Tests: PHPUnit 179/179, 684 assertions, all green. PHPStan clean
under phpVersion: 70400. Existing test references to the old
hand-picked constants (SECURED_BY, REVOKE_ACCESS_BUTTON, etc.)
updated to the auto-generated equivalents (SECURED_BY_TRUSTEDLOGIN,
REVOKE_ACCESS, CREATED_1_S_AGO_BY_2, SUPPORT_ACCESS_IS_TEMPORARILY
_UNAVAILABLE_PLEASE).

Naming-curation pass is a reasonable follow-up — the auto-generated
keys work but some are unwieldy. Doing this in a separate PR keeps
THIS one mechanical and reviewable.
The instance-method shape was forcing every class to carry a
private \$strings property and a constructor assignment, AND it
couldn't be used from static methods (Remote::check_response_code
had to leave 9 strings as plain esc_html__()).

Static shape removes all of that:

  Strings::init( Config \$config )   // called once by Client::__construct
  Strings::get( \$key, \$default )    // called from anywhere, including static methods
  Strings::reset()                  // test-seam for tearDown

What the refactor does:

- src/Strings.php: \$config and \$overrides become static. init()
  binds them, reset() clears them. get() and resolve() drop \$this
  for self::. A missing init() degrades gracefully — overrides
  fall through to defaults and the runtime filter is skipped.

- src/Client.php: one line — Strings::init( \$config ) right after
  Config validates. Subsequent SDK code uses Strings::get() without
  thinking about wiring.

- 11 classes (Admin/Ajax/Client/Encryption/Endpoint/Form/Remote/
  SecurityChecks/SiteAccess/SupportRole/SupportUser) lose their
  private \$strings property AND the constructor assignment
  \$this->strings = new Strings(\$config). Net 22 lines deleted.

- 108 call sites: \$this->strings->get( → Strings::get(. Mechanical
  sed across the codebase.

- src/Remote.php: the 9 strings in check_response_code() that had
  to remain as plain esc_html__() under the instance API are now
  migrated into the registry alongside the rest. Static methods
  participate fully.

- Tests: \$strings = new Strings(\$config) → Strings::init(\$config),
  \$strings->get(...) → Strings::get(...). reset_strings_state()
  in tearDown collapses to a single Strings::reset() call.

PHPStan: clean under phpVersion=70400 (after marking \$config as
Config|null since reset() unsets it).
PHPUnit: 179/179, 684 assertions.

Diff stat: 14 files changed, 206 insertions(+), 241 deletions(-).
Net -35 lines.
# Bug fixes (from code review)

## 1. EXTEND_S_ACCESS dropped a sprintf argument

src/Form.php line 1434 called sprintf with two args (vendor name +
human-readable duration) against a default '%s' format that only
consumed one. The duration silently vanished from the
"Extend Acme Access for 1 week" button. Fixed by:
  - Renaming the constant: EXTEND_S_ACCESS → EXTEND_VENDOR_ACCESS_FOR_DURATION
  - Bumping the registry from placeholders=1 to placeholders=2
  - Updating the default from "Extend %s Access" to "Extend %1$s Access for %2$s"

## 2. Config::validate_strings defensive cache flush

Config::get_setting() caches per-key. validate_strings() mutates
$this->settings['strings'] in place. The current Client::__construct
call order is safe, but a future refactor that touches
get_setting('strings') from inside validate() would silently bypass
override pruning. Drop the cache slot at the tail of validate_strings.

## 3. change_locale closure captured a stale textdomain

Strings::load_translations() registered the change_locale hook only
once but the closure captured $textdomain by value. A second
load_translations('beta-plugin') would update self::$textdomain but
the previously-registered closure still loaded .mo against the FIRST
textdomain. Closure now reads self::$textdomain at fire time.

# Translator-comment placement fixes

Added tests/test-translator-comments-extracted.php — a meta-test
that runs `wp i18n make-pot` against src/ and asserts every
// translators: comment in source survives extraction into the POT.

The test caught FIVE existing placement bugs where the comment was
on the outer sprintf/esc_html wrapper line, too far from the inner
__() literal for the extractor to associate it:

  - Form.php:380-381 (Contact/Email support text) — comment swapped
    onto the wrong msgid because of inline-comment heuristics
  - Form.php:845 (Site Health link) — multi-line wrap distance
  - Form.php:970 — orphaned comment for 'Visit the %s website…' that
    was hard-coded English (never translatable!). Fixed by wrapping
    in __() with a new VISIT_VENDOR_WEBSITE registry entry.
  - Form.php:1432 — the EXTEND comment I introduced in this PR
    while fixing bug #1. Repositioned inside the Strings::get() arg.

All comments now sit on the line immediately preceding the __()
literal, either inline (`/* translators: */ __(...)`) or on the
line above when the __() is on its own line.

# Coverage tests (from audit)

Added ~22 tests across three files closing the audit-identified
gaps:

tests/test-strings.php (+15 tests, +setUp/tearDown with reset()):
  - test_init_called_twice_replaces_bound_config
  - test_get_without_init_returns_default_no_filter
  - test_reset_clears_all_static_state
  - test_zero_arg_closure_override_invoked
  - test_throwing_closure_propagates_exception (documents current contract)
  - test_malformed_override_injected_post_validation_falls_back
  - test_runtime_filter_receives_four_args
  - test_filter_fires_on_override_path
  - test_strings_config_non_array_is_unset
  - test_escaped_percent_does_not_count_toward_placeholder_total
  - test_format_flags_recognized_as_placeholders (6-case data provider)
  - test_non_string_non_callable_overrides_dropped (7-case data provider)
  - test_mixed_valid_and_invalid_overrides_partial_keep
  - test_client_constructor_initializes_strings

tests/test-strings-translation.php (+5):
  - test_repeated_load_translations_registers_change_locale_once
  - test_second_load_translations_overrides_textdomain
  - test_change_locale_callback_reads_latest_textdomain_after_second_load
    (proves bug fix #3 — writes a temp .mo file to drive the closure)
  - test_mo_path_resolves_relative_to_strings_file (Strauss safety)
  - test_load_translations_with_non_string_argument_is_noop

tests/test-support-user-locale.php (+4):
  - test_get_user_locale_does_not_leak_across_current_user_switches
  - test_locale_format_check_does_not_consult_get_available_languages
    (uses zz_ZZ to prove the SDK accepts unlisted locales)
  - test_en_US_is_accepted_despite_being_absent_from_get_available_languages
  - test_strings_overrides_and_support_user_locale_coexist
    (cross-feature smoke test)

# Verification

PHPUnit: 209/209, 739 assertions (was 179/684 — +30 tests, +55 assertions).
PHPStan: clean under phpVersion=70400.
make-pot: every translator comment in src/ now lands in the .pot
as a `#.` extracted comment alongside its msgid.
PHP 8.0+ throws an uncaught ValueError when sprintf is called with
"Too few arguments" — a real fatal that would crash the consent
screen on a customer's site. Static-string overrides are already
placeholder-validated at Config-load time, but two paths bypass
that gate:

  - Closure overrides return arbitrary strings at render time.
  - The trustedlogin/{ns}/strings/{key} filter can mutate $value
    into anything.

Either path could return a string with MORE placeholders than the
SDK call site supplies args for. PHP 8 then fatals.

Defense (Strings::get tail): count placeholders in the resolved
value via Strings::count_placeholders(). If it exceeds the
registry-declared count for this key, fall back to a fresh
translation of the $default — which the SDK author hand-authored
against the matching arg count, so it's guaranteed safe.

count_placeholders() handles:
  - Simple %s / %d / %f
  - Flag + width + precision modifiers (%05d, %.2f, %-10s)
  - Positional placeholders (%1$s, %3$d) — counts by max index so
    reuse of the same slot doesn't double-count, and a gapped
    %1$s..%3$s still reports 3 args needed
  - Escaped %% — stripped before counting
  - Non-string inputs → 0

Fewer-placeholders is intentionally NOT rejected: closures often
return a fully-formatted string (no placeholders left) for a
placeholder-having key, expecting the caller's sprintf to silently
ignore the extra args. That's standard PHP behavior and explicitly
allowed.

# Tests (+5)

  - test_closure_returning_too_many_placeholders_falls_back_to_default
  - test_filter_introducing_too_many_placeholders_falls_back_to_default
  - test_closure_returning_fewer_placeholders_is_allowed (common case
    of fully-formatted closure return for a 2-placeholder key)
  - test_filter_calling_sprintf_inline_is_allowed
  - test_count_placeholders_handles_format_flags_and_positionals
    (12-case sanity sweep for the counter)

PHPUnit: 214/214, 755 assertions (was 209/739 — +5 / +16).
PHPStan: clean.
Two integrator-code paths run inside Strings::get() / resolve()
and could throw — taking the SDK with them:

  1. Closure overrides invoked via call_user_func_array().
     Common failure modes: undefined variable in the integrator's
     callable, null pointer ($x->method() when $x is null), DB
     timeout, type error in a strict-typed callback.

  2. apply_filters() callbacks for
     trustedlogin/{ns}/strings/{key}. WP itself does not catch
     filter exceptions.

Either propagated as an uncaught fatal would crash the customer's
consent screen — the integrator's bug becomes the customer's
outage. SDK now wraps both call points in `try { ... } catch (
\Throwable $e )` and falls back to:

  - Closure that throws → the translated SDK default.
  - Filter that throws → the pre-filter resolved value (so the
    override is still respected, just without the filter mutation).
  - translate() that throws (malformed .mo) → the raw English
    default with no further processing.

The catch covers `\Throwable` (PHP 7+ base of both \Exception and
\Error), so type errors and "call to undefined method" don't slip
through under \Exception-only catching.

When WP_DEBUG is on, failures are surfaced via error_log() with
the failing Strings constant + exception message — enough signal
for the integrator to diagnose without forcing them to wire a
logger.

# Tests

  - test_throwing_closure_falls_back_to_default
    (replaces the prior test_throwing_closure_propagates_exception
    which documented the OPPOSITE behavior — that contract is
    inverted by this change. Inverting a test rather than deleting
    it makes the decision change visible in git blame.)
  - test_throwing_filter_does_not_fatal_keeps_resolved_value
  - test_closure_that_throws_error_not_exception_also_falls_back
    (verifies the \Throwable catch covers \Error, not just \Exception)

PHPUnit: 216/216, 757 assertions (was 214/755 — +2 / +2).
PHPStan: clean.
# Wire failure logging through the SDK's own Logging class

Strings::log_closure_failure was using error_log() with a WP_DEBUG
gate — inconsistent with the rest of the SDK and harder for
integrators to route. Replaced with the SDK's own Logging class:

  - Lands in the same trustedlogin-logs/ file as every other SDK
    log line.
  - Respects the integrator's `logging/enabled` Config gate.
  - Fires `trustedlogin/{namespace}/logging/log_error` so
    integrators can hook to forward to Sentry, CloudWatch, etc.

Logging::__construct is three property assignments; the expensive
setup_klogger() lazy-fires on first write. Building one per failure
is fine for an error-path that rarely fires.

# Trim meta narrative

Following the codebase's commenting policy: keep comments that
describe hidden constraints / subtle invariants / surprising
behavior. Strip the "Defense:", "Belt-and-suspenders:",
"Per-class-plumbing", and "This was added because..." narrative
that describes the developer's reasoning rather than the code's
behavior.

Where:
  - src/Strings.php: get(), resolve(), load_translations(),
    init(), reset(), log_closure_failure() — kept method
    descriptions; trimmed narrative.
  - src/Config.php: validate_strings(), placeholders_safe() —
    same treatment.
  - src/SupportUser.php: locale-related comments trimmed to
    behavioral signal only.
  - src/Client.php: drop the "Bind the Strings utility AFTER
    validate()" rationale comment.
  - tests/*.php: drop the wide `// ====` / `// ----` section
    banners between groups of tests.

# Test
  - test_throwing_closure_logs_through_sdk_logging verifies
    log_closure_failure routes through Logging by hooking the
    SDK's logging/log_error action and asserting the message
    arrived.

PHPUnit: 217/217, 760 assertions.
PHPStan: clean.
- Strings::get() / resolve(): rename $default → $default_text (reserved
  keyword warning); add phpcs:ignore for intentional runtime translate()
  with variable textdomain.
- Strings: add short docblock descriptions, parameter comments,
  reformat constants registry (one entry per line, aligned).
- Config::placeholders_safe(): ignore set_error_handler dev-fn warning
  and add $template parameter comment.
- Anonymous functions: drop `static` modifier (PHP 5.4+); deferred-init
  callbacks for change_locale already use named static callbacks.
- AGENTS.md: add Comment Discipline section documenting PHP 5.3 runtime
  target and rules against meta-narrative / task-reference comments.
- Drop test_closure_that_throws_error_not_exception_also_falls_back —
  catching \Error is PHP 7+ and the SDK targets 5.3.

PHPCS, PHPStan, PHPUnit (216 tests / 759 assertions) all clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant