Changelog
Release history and version notes
1.0.0 Initial release 1.0.0
Everything below is in the first public release of PerfLocale. A performance-first multilingual plugin for WordPress. Translate posts, pages, products, taxonomies, strings, and slugs with a 3-layer cache (static → object cache → transients). Measured PHP wall-clock inside the plugin's own callbacks lands at about 1 ms median per request on typical multilingual sites; across 1,440 measured samples — single-site and multisite, no-cache / cold-Redis / hot-Redis, six configurations — not one exceeded 5 ms. Sites with more than ~2,000 translation links should run a persistent object cache (Redis or similar) to stay under 5 ms. Real-world numbers depend on your theme and other plugins. Every feature follows WordPress standards - prepared SQL, escaped output, nonce-verified forms, capability-gated endpoints - with no third-party PHP libraries.
- Core translation: posts, pages, any custom post type, categories, tags, custom taxonomies, and per-language URL slugs. Configurable fallback chains when a translation is missing (show_404, show_default, redirect_default).
- URL routing: subdirectory (/en/), subdomain (en.example.com), or per-language domain modes. Language detection from URL, cookie, browser preference, or GeoIP (IPinfo, IPinfo Lite, ipapi.co, ipstack, ip-api.com). Self-healing rewrite rules rebuild automatically if language patterns go missing.
- String translation: translate gettext strings from any plugin or theme without code changes. Two storage modes - pre-generated .l10n.php files for speed, or database mode with lazy-loaded gettext filters. Built-in scanner with MO/PO hint lookup. Settings → Performance → Regenerate Translation Files self-heals orphaned data (translations with missing translation_groups/translation_links rows) before writing files, and reports the reconnected count in the completion notice. Tools → Site Health detects the same orphan states and links to the regenerate action.
- Language switcher: Gutenberg block (inline / simple / dropdown), shortcode, automatic nav-menu integration, and admin-bar switcher. WordPress Block Hooks auto-insertion (FSE themes only) places the switcher after the Site Title in block-theme headers by default — opt-out via Settings → Language Switcher, retarget via the perflocale/switcher/auto_insert_anchor filter, or rewrite the default attributes (e.g. force a compact dropdown in header context) via perflocale/switcher/auto_insert_attrs. Full ARIA listbox accessibility and keyboard navigation - role="listbox" + aria-labelledby on the panel, role="option" + aria-selected on each item, aria-haspopup="listbox" + aria-expanded + aria-controls on the trigger, aria-disabled on untranslated options, aria-current on nav-menu items; Enter / Space / ArrowDown opens + focuses option 1, ArrowUp opens + focuses the last option, arrows / Home / End move focus inside the panel (wrapping), a single printable character does first-letter type-ahead (mirrors native <select>), Esc closes the panel and returns focus to the trigger. Every dropdown option, the dropdown trigger button, inline / list / simple-mode option links, and the current-language span carry lang="<target-slug>" and dir="rtl" when the target language's text_direction is RTL - the visible text inside each option is in the TARGET language ("Français", "العربية") not the page language, so screen readers need lang to pronounce labels with the right phonemes and the browser needs dir="rtl" to render RTL labels right-to-left even on an LTR page. The Gutenberg block honors WordPress's align (left / center / right / wide / full), spacing (padding / margin), color, typography, and className block supports on the frontend - the server-side render passes through get_block_wrapper_attributes() so editor choices reach the page and themes' is-layout-constrained rules can't stretch the dropdown trigger to the full content-area width. Per-instance Dropdown Arrow control (single chevron, double-stacked chevrons, or none, with a perflocale/switcher/arrow_html filter for theme custom icons) and per-instance Trigger Label Format (inherit / native / english / both / slug - lets a header pill show "EN" while the dropdown lists full names) are available on every surface that exposes the switcher - Settings → Switcher, the floating Customizer switcher, Blocksy header + footer language-switcher components, Kadence Customizer, the Neve header builder component, and the Gutenberg block - and only render when Display Mode is Dropdown so the fields are hidden as dead UI for inline/simple modes. perflocale/switcher/panel_before + perflocale/switcher/panel_after filters inject HTML at the start / end of the dropdown panel (inside the listbox, around the options) for region groupings, search inputs, branding chrome, etc.; sanitised through kses_switcher() with an extensible allowlist via perflocale/switcher/kses_allowed_html. Dropdown CSS uses logical properties throughout (inset-inline-start, padding-block / padding-inline, margin-inline-start, …) so the panel anchors to the trigger's inline-start edge on LTR pages and inline-end on RTL pages without a separate RTL stylesheet; the JS horizontal-overflow flip reads getComputedStyle(panel).direction to mirror the same convention. Every dropdown colour, surface, spacing, timing, and z-index value is exposed as a --perflocale-dd-* CSS custom property with a hardcoded fallback (30+ vars) - themes recolour / resize the switcher by overriding the relevant vars instead of restating rules; @media (prefers-reduced-motion: reduce) disables cosmetic transitions for users who opt out at the OS level. Live panel repositioning while open: scroll (capture, passive) + resize + a ResizeObserver on the trigger button re-run the panel's positioning if the page reflows (sticky-header scrolls, viewport rotation, mobile browser chrome show/hide, web-font swap); listeners attach on open and detach on close so the cost is paid only while the panel is visible; vertical anchor flips above if there's no room below (accounting for the WP admin bar); horizontal anchor flips to inline-end if the inline-start anchor would push the panel past the viewport edge. Per-trigger click + keydown listeners (not one document-level handler firing on every event) - closest('.perflocale-switcher-block--dropdown') no longer runs for every click / keystroke on the page; a single document-level click listener is kept ONLY for outside-click-to-close. A MutationObserver picks up switchers added to the DOM after initial setup (Customizer previews, AJAX widgets, page-builder live previews, SPA navigations) so they receive the same listeners without integrators having to call a setup function. perflocale/switcher/option_content filter rewrites each option's inner HTML (flag + label) from a single hook fired on every render surface, for <bdi> wraps on mixed-direction labels, region badges, per-language icon overrides, etc.; return is sanitised through the switcher's kses allowlist before injection, including on the Gutenberg block render path that WordPress's do_blocks() doesn't otherwise sanitize. Filter returns from arrow_html / panel_before / panel_after / option_content are sanitised at the injection point via kses_fragment() — defense-in-depth so the block path matches the addon path's safety guarantees, with per-request sha1 memoisation in kses_switcher() so repeated identical fragments share one wp_kses parse, and a perflocale/switcher/sanitize_output filter lets sites that fully trust their callbacks opt out of wp_kses entirely while keeping the per-attribute esc_*() calls intact. Trigger uses :focus-visible (not :focus) so the keyboard focus ring shows only for keyboard / programmatic focus and not after a mouse click. Panel positioning math accounts for the --perflocale-dd-panel-offset custom property when checking viewport space so themes overriding the default 4 px gap to 12-16 px don't get edge-case flips on tight viewports where the panel would actually fit. Three bubbling CustomEvents for JS integrations: perflocale:switcher:open and perflocale:switcher:close fire after the panel state changes (including when a switcher is auto-closed because another opened, or by an outside click); perflocale:switcher:navigate fires BEFORE the browser follows a clicked option's URL and is cancelable — call event.preventDefault() to keep the user on the current page (unsaved-changes guards, pre-switch analytics with navigator.sendBeacon, custom redirect middleware, A/B testing tools). Events bubble so listeners can attach to the <nav> directly or to document and observe every switcher from one place; detail object carries nav / trigger / panel / option / slug / url as relevant. Per-instance Trigger Label Format + Dropdown Arrow are exposed on EVERY rendering surface — Gutenberg block, shortcode (trigger_format="…" + arrow_style="…"), PHP template tag, WP widget, Elementor / Beaver Builder / Bricks page-builder integrations, the Customizer floating switcher, and the four bundled theme addons (Blocksy header + footer, Kadence header, Neve header builder); empty / unset values fall through to the global Settings → Switcher defaults.
- SEO: hreflang tags in both <link> head and HTTP Link response headers with x-default. WordPress sitemap integration with alternate URLs. Schema.org JSON-LD enrichment (inLanguage + workTranslation) for Yoast, Rank Math, AIOSEO, SEOPress, Slim SEO, The SEO Framework. Content-Language HTTP header (BCP-47). data-nosnippet guard around fallback content. IndexNow push-indexing to Bing/Yandex with sibling-URL coordination. Speculation Rules prerender (WP 6.8+ Core API or standalone fallback on 6.4–6.7). View Transitions API crossfade on language switch with prefers-reduced-motion respected.
- Machine translation: DeepL, Google, Microsoft, LibreTranslate, plus the WordPress AI Client (WP 7.0+) — translate via your host's already-configured AI provider (OpenAI / Anthropic / local Ollama / etc.) without provisioning a second key. Auto-translate on publish, bulk translation from the Translations admin page (small batches inline, larger ones queue as bulk_translate jobs visible under PerfLocale → Jobs), monthly character-limit tracking. Glossary enforcement for brand names and technical terms. Translation memory with configurable fuzzy-match threshold. SSRF-protected requests with SSL enforcement and exponential backoff retry. Opt-in AI quality scoring (hourly background job that asks the active AI provider to rate MT-translated rows 1-5 and flags low scorers for human review). Glossary suggest-as-you-type endpoint when the active provider is an AI client.
- API key configuration: every machine-translation, GeoIP, and exchange-rate credential is resolved in env-var → wp-config.php constant → WordPress Connectors API (WP 7.0+, feature-detected) → database priority order. Lets containerised / version-controlled deployments keep secrets out of wp_options; multi-plugin sites get a single key-rotation point via Connectors. The Connectors layer is filter-extensible (perflocale/connectors/slug_map, perflocale/connectors/resolver) and stays a no-op on older WP versions.
- Translation workflow: custom translator role, assignments with priorities and deadlines, and a clear status pipeline (Unassigned → Assigned → In Progress → In Review → Approved → Published). Email notifications in the recipient's admin language. Publish gate to prevent unapproved translations from going live.
- Block editor experience: the Gutenberg link picker (Insert Link toolbar + ToC block) is now scoped to the language of the post being edited. An apiFetch middleware adds the open-post id to every /wp/v2/search call and the server-side filter resolves that id to a language, applies strict-mode language filtering on the WP_Query, AND re-prefixes the returned URL through UrlConverter::convert() so editors of a /de/ post only see DE pages with /de/ URLs - even on legacy data shapes where one post is registered for multiple languages in the same translation group (imported sites). Posts that are still untranslated fall back to the site default language for consistency. The Featured Image per Language metabox now only renders when an override would actually take effect for the open post: it hides languages that already have their own sibling translation post (their _thumbnail_id is the answer) AND the language the post itself is registered for (an override would just shadow the default Featured Image box), and the whole metabox is skipped entirely when no useful row remains - properly translated posts no longer carry an empty card. The metabox also mirrors WordPress core by checking both post_type_supports('thumbnail') and current_theme_supports('post-thumbnails', $post_type), and two new filters (perflocale/media/show_featured_image_panel and perflocale/media/featured_image_panel_languages) let themes / addons host the language overrides in their own UI. The Block translation sidebar's sibling button label was shortened (Translate from {lang} · {provider}) so it fits the 280-px Gutenberg sidebar without truncation on long source-language names.
- WooCommerce: translate products, variations, categories, attributes, and attribute terms. Variation attribute names translated in cart, mini-cart, checkout, and order emails. Multi-currency with automatic exchange-rate sync (ECB, Open Exchange Rates, Fixer, CurrencyFreaks, Frankfurter). Inventory sync mirrors stock / SKU / weight / dimensions across language variants. Order emails sent in the customer's checkout language and stored on the order for later re-sends.
- 20+ auto-detecting integrations: WooCommerce; Elementor, Beaver Builder, Bricks, Oxygen Classic, Oxygen 6+; Yoast, Rank Math, AIOSEO, SEOPress, The SEO Framework, Slim SEO; ACF, Meta Box, Pods; Gravity Forms, Contact Form 7, WPForms; Blocksy, Kadence, Neve.
- Addon system for third-party authors: three capability interfaces (HasSchema for versioned database tables, HasUninstallTargets for declarative purge targets, HasCustomUninstall for external resource cleanup). Each addon lives in its own namespaced schema (wp_perflocale_addon_{addon_id}_{short_name}) with pattern-validated short names, strict uninstall-prefix enforcement, and manifest-backed orphan safety.
- Migration: bundled importers for WPML, Polylang, and TranslatePress. Preserves translation groups, string translations, term translations, and slug mappings. All three importers fetch source rows in tunable batches (filters perflocale/migration/wpml/batch_size, perflocale/migration/polylang/batch_size, perflocale/migration/translatepress/batch_size) so peak memory stays bounded on 50,000+ post sites. Migration guides published for each source plugin.
- Developer API: 200+ action and filter hooks (including 17 WordPress 7.0+ integration hooks for the AI Client provider, quality scoring, glossary suggest, Connectors API resolver, JS Abilities shim, and Block Hooks switcher auto-insertion), a full REST API (translations, machine translation, glossary, translation memory, XLIFF round-trip, webhooks, background jobs), WP-CLI commands (language management, bulk translation, string scanning, slugs, cache, glossary CSV, PO files, addons, health checks, migration, full-site export/import, multisite network export/import, background jobs control), WordPress Abilities API (WP 6.9+) with 6 opt-in abilities for AI tools, and a PHP helper API for theme developers.
- Background processing: long-running operations (XLIFF data imports, data exports, bulk machine translation, WPML / Polylang / TranslatePress migrations, full-site string scans, multi-megabyte glossary CSV imports, AI quality scoring) move off the request path automatically. Action Scheduler when loaded, WP-Cron fallback. State persists in a dedicated `wp_perflocale_jobs` table with typed columns and indexes on uuid, (status, updated_at), (type, status), and created_by — no public pageview ever queries it. Short-lived advisory locks still use atomic `INSERT IGNORE INTO wp_options` for race-free row-level mutex semantics. Cooperative cancel-mid-flight, retry-with-exponential-backoff (5 attempts default), per-type concurrency cap, operator pause toggle, daily GC for stuck/orphaned jobs and stale lock rows, reactivation resume so jobs survive a deactivate/reactivate cycle. PerfLocale → Jobs admin page (with a single-use Download button for completed data-export jobs) + 'wp perflocale jobs' CLI + 5 REST endpoints. Per-job thresholds configurable via Settings → Performance → Background Thresholds. Per-blog isolation on multisite. Two bulk admin AJAX handlers (Create WooCommerce page translations, Create taxonomy translations) raise PHP time-limit only inside their nonce + manage_options-gated bodies; the value is filterable via perflocale/admin/bulk_time_limit (default 0 = no limit).
- Multisite: per-subsite languages, translations, glossary, and workflow state. Auto-provision on new subsite creation. Clean per-subsite uninstall respecting each subsite's own delete_data_on_uninstall preference. Chunked network activation (filter perflocale/activation/chunk_size) so thousand-subsite networks don't exhaust memory.
- Performance: three-layer cache (per-request static array → WP object cache → transients), batch-preloaded translation lookups on the_posts, conditional hook registration so disabled features add no listeners, LEFT JOIN queries instead of NOT IN subqueries for language filtering, language-specific WooCommerce cart fragment keys, and an autoloaded eager-link-map that holds every translation_link in one option on sites under ~2,000 links. Measured plugin code time (PHP wall-clock inside PerfLocale callbacks): about 1 ms median on typical multilingual sites; across 1,440 measured samples — single-site and multisite, no-cache / cold-Redis / hot-Redis, six configurations — not one exceeded 5 ms. Sites with more than ~2,000 translation links fall through to the per-link cache path and should run a persistent object cache to stay under 5 ms. Real-world numbers depend on theme + co-installed plugins; full methodology at perflocale.com/benchmarks/.
- Security: prepared SQL throughout, escaped output at every render site, nonce verification on every form and AJAX request, capability checks on every privileged action, HTTPS + SSL verification on every external HTTP request. No third-party PHP libraries.
- Privacy & accessibility: GDPR-integrated. Tools → Export Personal Data returns a user's translation-workflow assignments AND their PerfLocale admin-UI preferences (per-page list lengths and hidden-language column choices); paginated at 100 workflow rows per page so translators with thousands of assignments don't time out the request handler. Tools → Erase Personal Data runs five steps in one pass — anonymise workflow rows (zero assigned_to, status → unassigned; surrounding rows + notes preserved so other contributors' history isn't disturbed); scrub the data subject's email, login, display name, and (uniqueness-gated) first/last/full name out of every workflow row's notes field; scrub the same patterns out of every matching translation-memory row's source_text and target_text so user-generated content cached in TM doesn't retain PII; zero created_by on every active background-job row the user dispatched (worker cap re-validation then fails the job cleanly); delete the per-user UI-state meta. Returns integer counts in items_removed (UI-meta deletions + job-row anonymisations) and items_retained (workflow-row anonymisations + workflow-note scrubs + TM scrubs) so the data subject sees what survived in what form. The same scrubbing + anonymisation flow also fires on the admin-driven delete_user path so admins don't have to file a GDPR request to get a clean cleanup. WCAG 2.1 AA-aligned across the language switcher, admin UI, and View Transitions; axe-core audited on release.