AI Translator for Polylang & WPML
AI translation of posts, WooCommerce products, terms and ACF fields into other Polylang or WPML languages via Vercel AI Gateway.
Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/riodejaneiroo/ai-polylang-translator/archive/refs/heads/main.zipWordPress plugin that translates posts, pages, WooCommerce products, taxonomy terms, Yoast SEO fields (and copies Yoast's robots settings, social images and primary term) and ACF fields into the other languages of a Polylang or WPML site, using models served through the Vercel AI Gateway. The multilingual plugin keeps owning languages, translation groups and sync; this plugin only supplies the translated text.
Plugin slug, text domain and file names stay ai-polylang-translator for compatibility with existing installs.
Requirements
- PHP 8.1+, WordPress 6.0+
- Polylang or WPML (Multilingual CMS). If both are active, the plugin refuses to translate and shows a notice until one is deactivated.
- A Vercel AI Gateway API key
- Optional: WooCommerce (+ WooCommerce Multilingual on WPML), Yoast SEO, ACF
Settings (Settings > AI Translator)
- Status / Enable AI Translator: the global switch. Off parks the plugin so the multilingual plugin's own translation tools can be used alone: no AI Translation box, every AJAX request refused, no automatic translation (queued events do nothing),
wp aiptrefuses to start and a running bulk command stops before the next record. Settings, the cost log and the API-key test stay available; turning it back on needs no migration. - API: key (write-only: an empty field keeps the stored key), key test, model, site context for the prompt.
- Post types: which public post types (including
product) and reusable blocks (wp_block, inserted into pages by reference; translate them so translated pages do not show the source language) get the AI Translation box and can be translated. A translated page'swp:blockrefs are written pointing at the target-language block when it has a translation, and on the front end a translated page shows the target-language block when it has a translation, whichever was translated first. - Yoast SEO (shown when Yoast SEO is active, default on): Translate Yoast SEO fields translates the SEO title, meta description, breadcrumb title, focus keyphrase and the Facebook and X (Twitter) titles and descriptions, and copies the robots settings (noindex, nofollow, advanced), the Facebook and X images and the primary category or term (replaced with its translation; left unset when the term has none). Canonical URL and redirect are never copied. Placeholder-only templates (
%%title%% %%sep%% %%sitename%%) are copied as they are; a translation whose placeholders changed keeps the source value, and the write reports a warning naming the field (the translation's status is not affected). Safe mode keeps filled values of the translation, and an empty source value never clears one there. In overwrite mode an update makes the Yoast fields follow the source: an SEO title, description or focus keyphrase, robots setting, image or primary term the source no longer has (or a primary term without a translation) is deleted from the translation. Unticked (or with Yoast SEO inactive), Yoast SEO fields are not touched. Other Yoast keys a multilingual plugin lists (e.g. WPML's translate-mode keyword keys) are never copied. Other keys the multilingual plugin copies to new translations (Polylang integrations, WPML custom fields set to copy, copy once or translate) are copied the same way. A new translation gets every copied key; on an update, filled values of copied keys are kept, except Yoast's robots, images and primary term in overwrite mode, which follow the source. - Automatic translation: translate a post in the default language when it is first published (off by default), target languages.
- Advanced:
- Extra meta keys to translate: plain-text custom fields, one meta key per line (e.g. a price-unit label). Only non-empty string values are sent; an empty source value never clears the translation's value. Yoast SEO keys cannot be listed (see Yoast SEO).
- Copy meta keys: custom fields copied to translations as they are (not translated), one meta key per line; a trailing
*matches a prefix (e.g._kad_post_*for Kadence page settings). Applied when a translation is created or updated (also in safe mode): for every source key that matches, the translation's values are replaced with all source values. Translated keys (extra meta keys, Yoast SEO, ACF, WooCommerce attributes) and internal keys (_thumbnail_id,_wp_page_template,_edit_lock,_edit_last,_wp_old_slug,_aipt_*, the multilingual plugin's own keys) are never copied. Keys the multilingual plugin copies itself (see Yoast SEO) are excluded from this pass: they follow those rules instead of being replaced in full. Yoast SEO keys listed here are ignored. - Copy term meta keys: the same for term meta (e.g.
thumbnail_id), applied when a translated term is created or linked bywp aipt translate-terms, the bulk page or automatic translation. - WPML translation editor (WPML only, default on): see below.
- ACF fields: per-field translate / copy / don't touch overrides.
- Cost log: the latest 100 translations with tokens and cost, plus totals.
Usage
- Editor: the "AI Translation" box on a post lists the target languages. A new translation is created as a draft; an existing one asks for confirmation before it is overwritten, or can be updated in safe mode (filled fields of the translation are kept). Failed steps can be retried.
- Automatic translation on publish: a background event translates the post's missing terms, then the post, into every selected language without a real translation. Existing translations are never updated. See below.
- Bulk translation page (Settings > AI Bulk Translation): terms, then posts, products and pages from wp-admin, without SSH. See below.
- WP-CLI:
wp aipt translate(bulk posts, resumable,--dry-runshows the estimated cost),wp aipt translate-terms(term names and descriptions) andwp aipt translate-strings(WPML String Translation strings, see below). Seewp help aipt translate.
Automatic translation
When Translate automatically on publish is on, a post of an enabled type in the default language gets one WP-Cron event when it is published: 10 seconds after a publish from the classic editor, Quick Edit, WP-CLI or code (and when a scheduled post goes live), two minutes after a publish from the block editor (it saves ACF, Yoast and the language box in a second request, which must land first). The event translates the post's missing terms, then the post, into every selected language that has no real translation, published with the source date. Existing translations are never updated; posts the plugin wrote itself since 1.7 are never auto-translated.
- Status: the AI Translation box of the source post shows the state of its automatic translation above the language rows: when it is scheduled, since when it is running, when each language was translated, why a language failed or was skipped (e.g. its parent page or terms have no translation yet), and whether a translation already existed (it is left alone). An event that is overdue but still queued is shown as such: WP-Cron has not run it, see WP-Cron below. The status is read when the page loads; reload the post to see a run's result. Every failure and skip is also written to the PHP error log (
AIPT auto-translate: …); successes are not logged. - Retries: connection errors, timeouts, rate limits (HTTP 429) and server errors (HTTP 5xx) of the AI Gateway, and a translation another process is writing at that moment, are retried automatically for the affected languages, 5 and then 30 minutes later. Other errors (invalid model output, a safety filter) fail that language, and the other languages still run. Errors that would repeat for every language (the API key rejected or missing, the multilingual plugin's API or translation link failing) stop the post: the remaining languages are marked as not attempted and the post can be retried.
- Retry by hand: when the last run failed, its event was lost (gone from the cron queue: the plugin was deactivated with it queued, or a fatal error before the run started) or the run stalled (no progress for an hour), the box shows Retry automatic translation, as long as the post can still be translated automatically (published, in the default language, an enabled type, the setting on). Re-publishing the post (draft → publish) does the same. Languages already done are kept; only languages without a real translation are translated. Posts marked by versions before 1.8.0 (no status recorded) that still lack a selected language show a neutral line listing those languages and Translate missing languages automatically, and re-publishing them works too; only languages without a real translation are translated (also a language selected later). Posts the plugin wrote since 1.7 are excluded; default-language posts it wrote before 1.7 count as such older posts.
- Page caches: after every translation write the plugin fires
aipt_purge_urlswith the permalinks of the translation and its source (see Hooks), and purges both posts in LiteSpeed Cache when it is active. - WP-Cron: events run when someone visits the site. On sites with
DISABLE_WP_CRONor little traffic, set up a real server cron that runs WP-Cron every minute, e.g.* * * * * wp --path=/path/to/site cron event run --due-now(or a request tohttps://example.com/wp-cron.php); otherwise the translation waits for the next page view.
Bulk translation without SSH
Settings > AI Bulk Translation (administrators, manage_options) translates whole post types and taxonomies from wp-admin. Choose post types (those enabled in the settings; published posts in the source language), taxonomies (translated ones in which you can create terms; the term count follows the chosen source language, and a taxonomy without terms in it is greyed out with "(0)"), source and target languages, what to do with existing translations (skip, the default; update in safe mode; overwrite, with a confirmation box), whether new translations are published (sources that are not public stay private, scheduled or draft, as with wp aipt translate --publish) and keep the source dates.
- How it works: the open browser tab drives the run, one request at a time per stream: each selected taxonomy × language first (one short request per group of missing terms that fits in two API calls — up to 120 short names, fewer with long descriptions — created right away), then every post × language: prepare → one request per block → save. There is no queue, cron or background process; the database is the state. Every finished block is saved in a durable job (
aipt_bulk_job_<post>_<lang>points to it), so whatever happens to the tab, the connection or the computer, a block that was saved is never paid for again. The one residual risk is a single block whose reply was billed but never saved because its request was killed mid-flight. - Keep the tab open and the computer awake until the run ends (roughly 2 hours for 1,500 products into one language). Leaving the page asks for confirmation.
- Stop and resume: Stop lets the requests of the records in progress finish (including their retries) and starts nothing new. Start again to continue the same run: records finished in this run are skipped in every mode, also in safe and overwrite mode (each finished record leaves a small marker, and the tab keeps a run token from the first Start); unfinished records resume from their saved blocks; in skip mode, records translated earlier are skipped too. A run ends when it finishes by itself or when you click New run; the next Start then begins a new run, which in safe or overwrite mode translates every selected record again. The finished-record markers are site-wide: a new run started by any administrator clears them for everyone, so run one bulk translation at a time. The run stops after 5 errors in a row, at once on errors no record can get past (plugin disabled, no API key, expired page, missing permissions, a multilingual-plugin API failure), and when the API key is rejected (also during terms).
- Estimate first: Estimate runs the same pre-checks as a real run and the
wp aipt translate --dry-runcost estimate, without API calls or writes: per status counts (would create, would update, skipped and why), characters and cost, terms included. - Needs manual check: a record is never written automatically when a previous run started writing it and did not finish (the request died inside the write), when its source was edited, changed status or lost its parent or term translations after its blocks were translated, when its translation was created or removed elsewhere after its job was prepared, or when its unfinished job belongs to another user or other options (mode, publishing, dates). Such records are listed in the "Needs manual check" panel with links to the source and, if one was written, the translation. Translate anyway (only for the user who started the job) asks for confirmation, discards the job and translates that one record again in overwrite mode (the blocks are paid again); Discard (any administrator, so stale entries can always be cleared) removes the job. The panel also lists unfinished jobs of earlier runs, which resume on the next Start.
- Order: terms first (product categories and tags,
pa_*attribute terms, categories), then products, then pages. Posts whose terms or parent page have no translation are skipped (terms not translated,parent not translated) and picked up by the next Start once they do. Pages run parents first: a child page never starts while its parent is still queued or in progress. An interruption re-pays at most one group of terms (cents). - Parallel requests (1–3, default 2): records translated at the same time; two languages of the same source never run at once. Each stream occupies one PHP worker for up to a minute per block, so keep 1–2 on shared hosting, where visitors need the remaining workers. Terms always run one request at a time.
- Soft limits: the cost limit (USD) stops starting new records and blocks once the run's cost reaches it; records whose blocks are all translated are still saved. The cost counter on the page is a convenience for this tab (it starts at zero on every Start); the cost log on the settings page is authoritative, per record.
- Log: errors, warnings, skips and records needing a check are listed with their reason; the details of pre-check and estimate rows use the same English wording as the WP-CLI log.
- Server notes: requests continue after the tab goes away (
ignore_user_abort), best effort: PHP-FPM time limits still apply, and on LiteSpeed setnoabortforadmin-ajax.php(LiteSpeed otherwise stops PHP when the client disconnects). - Before and after: back up the database before a large run; do not translate the same posts with WP-CLI or the editor at the same time, and keep automatic translation off during the run; purge page caches when it ends (the
aipt_translation_writtenhook fires per post). - Strings (WPML String Translation): a separate section at the bottom of the page translates WPML String Translation strings without SSH, like
wp aipt translate-strings. It lists the domains (WPML "contexts") that have strings in the chosen source language, with the number of strings and, per selected target language, how many have no finished translation. Tick domains and languages, optionally Update existing translations (=--mode=update), then Estimate (dry run: no API calls, no writes; counts per status, characters and cost) or Translate. The tab sends each domain × language in short requests of a few dozen strings each (at most two gateway batches, so an interruption re-pays at most one request), shows a summary row per domain × language (created, updated, skipped and why, errors, cost) and logs every translated, would-be-translated or failed string with its text or reason. Same rules as the command: placeholders and HTML are kept, a translation with changed placeholders is not saved, empty and placeholder-only strings are skipped. Stop ends the run after the current request. On Polylang, or WPML without String Translation, the section only says that strings are not supported.
WPML
On a WPML site the plugin writes only translated text; language assignment, translation groups (trid), TM status, media and WooCommerce Multilingual sync stay WPML's job and are reached only through WPML's public hooks. If a hook the plugin relies on is missing (for example after a WPML update), nothing is written and an error is shown.
- Duplicates: a WPML duplicate counts as untranslated. It is overwritten in place (same post ID, always in full, never in safe mode), the duplicate flag is removed, and automatic translation and
wp aipt translatetreat it as missing. - Pending translation jobs: a language pair with translation work in progress in WPML (a Translation Management / Advanced Translation Editor job or a placeholder) is never written. The editor shows a message, WP-CLI and automatic translation skip the pair.
- TM status: after writing, the translation is marked complete in WPML Translation Management, so the Translation Dashboard shows it as translated.
- Native editor setting: when the first AI translation of a post is written, the original is switched to WPML's native-editor mode, so opening the translation in wp-admin does not redirect to WPML's translation editor. Can be turned off in Advanced settings.
wpml-config.xmlkeeps the plugin's internal post meta out of WPML's custom-field sync.- Strings (WPML String Translation):
wp aipt translate-strings --domains=<d1,d2> --to=<langs> [--from=<lang>] [--mode=update] [--dry-run] [--log=<path>] --user=<admin>translates the strings of the given domains (WPML "contexts", e.g.admin_texts_wpseo_titlesfor Yoast SEO title templates,yoast-seo-term-metafor SEO titles and descriptions of terms) whose language is the source language, in batches, and saves each one as a finished WPML string translation. Placeholders (%%title%%,%s,%d,{{…}}) and HTML are kept; a translation whose placeholders changed is not saved and is logged as an error. Empty strings and strings with only placeholders or markup are skipped (WPML shows the original), and strings that already have a finished translation are skipped unless--mode=update. Same rules astranslate-terms:--user(withmanage_options), the global switch (checked before each domain × language),--logrequired unless--dry-run, exit status 1 when any string ended in error. Not available on Polylang. Without SSH, use the Strings section of Settings > AI Bulk Translation (see above).
WooCommerce
Products translate their title, description, short description, Yoast fields and extra meta keys, plus the custom (non-taxonomy) product attributes: each option of an attribute value is translated, and the attribute label is stored where WooCommerce Multilingual reads label translations. Safe mode keeps filled options and labels of the attributes the source has; the set of attributes always follows the source (WooCommerce Multilingual rebuilds it from the original), so attributes that exist only on the translation are dropped. Taxonomy attributes (pa_*) are terms and are translated with wp aipt translate-terms. Prices, SKU, stock, images, variations and taxonomy links are synced by WooCommerce Multilingual on WPML.
Gutenberg and Kadence blocks
Block delimiter comments (,) are replaced with [[AIPT-B<n>]] tokens before content is sent to the model, so block JSON is kept out of the model's input and restored from the source afterwards. If the model drops or changes a token in a part of the content, or a part's block markup cannot be masked, that part keeps its original text, a new translation is left as a draft, and the translation is reported with a warning.
Text kept in block attributes (the JSON of the delimiter, e.g. Kadence button labels or navigation link labels) is translated too: each text value is sent as a separate short item, and the translation is written back into that delimiter, with the rest of its JSON unchanged. Which attributes hold text comes from generic sources, never from a built-in list of blocks: the <gutenberg-block> definitions of the wpml-config.xml files that block plugins and themes ship (on WPML, the list WPML itself parsed from them; on Polylang, the files of the active plugins, mu-plugins, the active theme and its parent and wp-content/polylang/wpml-config.xml, re-read when one of them changes), plus the block attributes WordPress marks as content ("role": "content", type string, not stored in the block HTML) whose names do not look like settings (URLs, link targets, types, slugs, icons, dates, sizes, IDs …); for those keys a lowercase one-word value such as _blank or image is never sent either. Keys typed as links, media or IDs, and values that are URLs, paths, anchors, e-mail addresses or numbers, are never sent. A site can add or remove keys with the aipt_block_text_attributes filter (see Hooks). Attribute texts are translated only together with the content (in safe mode, a kept content keeps its attributes too).
Hooks
aipt_translation_written ($post_id, $source_id, $lang, $created) fires after every translation write, once meta, attributes and terms are saved, right after clean_post_cache() of the translation and the source.
aipt_purge_urls ($urls, $post_id, $source_id, $lang) fires from aipt_translation_written (priority 20): $urls are the permalinks of the translation and the source that are published. Hook it to purge both pages in a page cache (the source's language switcher and hreflang links change too). With LiteSpeed Cache active, the plugin also purges both posts through litespeed_purge_post.
aipt_translated_meta_keys ($keys, $post_id) filters the post meta keys sent for translation besides Extra meta keys (applied to []; while Yoast SEO is active and its box is ticked, the plugin adds Yoast's translated keys, AIPT_Yoast::TRANSLATE_KEYS, at priority 5); listed keys are not copied by the copy pass. Example: $keys[] = 'my_subtitle';.
aipt_copied_meta_keys ($keys, $source_id, $target_id, $lang) filters the meta keys copied from the multilingual plugin's list (see Yoast SEO). While Yoast SEO is active and its box is ticked, the plugin adds Yoast's copied keys (AIPT_Yoast::COPY_KEYS: images, robots) and the source's primary-term keys at priority 5; its own exclusions run last (PHP_INT_MAX): every other Yoast key (canonical URL, redirect, keyword keys), and every Yoast key while the box is unticked or Yoast is inactive. Example, a WPML site that set a field to ignore: return array_diff($keys, array('my_field'));.
aipt_block_text_attributes ($config, $backend) filters which block attributes are translated: $config is blockName => [attribute key => key config] in the shape of the key part of WPML's <gutenberg-block> config ([] = text; type = not text, e.g. link; search-method = regex for a regex key, * is a wildcard; children for nested arrays; encoding = json for URL-encoded JSON values), $backend is polylang or wpml. Example: $config['my/cta']['buttonLabel'] = array();.
Testing
There is no automated test suite. After a change, run php -l on every PHP file (see CLAUDE.md for the commands and the multilingual boundary grep), then check manually:
- Polylang site: Settings page (key test, cost log), the bulk page (estimate, a short run, Stop and Start again resuming an unfinished record, the "Needs manual check" panel), metabox new translation / overwrite / safe mode / retry,
wp aipt translate --dry-runand one real record,wp aipt translate-terms, automatic translation on publish (the status lines in the AI Translation box and_aipt_auto_statuson the source, a failed run retried from the box), the disabled state (box hidden, AJAX refused, cron no-op, CLI exits with status 1). Test with and without ACF and Yoast. - WPML site: the same flows, plus overwriting a duplicate (flag removed, TM status complete), refusal while a translation job is pending, the Translation Dashboard showing AI translations as translated, the translation opening in the WordPress editor, a simple and a variable WooCommerce product (labels and options translated, variations, prices and stock synced by WCML), a Kadence page (block markup unchanged), and no PHP notices in cron and WP-CLI runs.
Changelog
See CHANGELOG.md.