Machine translation of a WordPress post is easy to demo and hard to ship. The demo pipes post_content into a model and writes the reply straight back. The shipped version has to survive a glossary that says “Ledger” must never become “Konto”, an editor who reopens a draft six weeks later, and a multilingual plugin the client swaps out next quarter.
This tutorial builds the second version. You get a translation pipeline that splits content along block boundaries, sends each chunk to an LLM with a site glossary and a strict JSON schema, writes a draft in the target locale, records a source hash so a stale translation can never silently overwrite a newer one, flags the result as needing human review, and links locales through a small adapter layer instead of calling Polylang or WPML functions inline.
What this workflow does — and what it refuses to do
It translates. It does not publish. Every generated post lands as draft with a review flag, because an LLM will happily translate a product name into a competitor’s trademark and you will not notice until a customer emails.
It also refuses to treat translated HTML as trusted. The model returns HTML strings, and every one of them passes through wp_kses_post() before it touches the database. If the model hallucinates a <script> tag or an onclick attribute, the sanitiser eats it. This is not optional politeness — it is the difference between a translation tool and a stored XSS vector.
Prerequisites
- WordPress 6.5 or later, PHP 8.1 or later (the code uses union return types and named arguments are avoided for clarity).
- WP-CLI access on the server, or at minimum the ability to run
wp eval-file. - An LLM endpoint that supports JSON-schema-constrained output. Any provider offering a
response_formatwith a schema will do; the request shape below is generic. - A glossary you can version. A plain option row is fine; a custom table is better if you have more than a few hundred terms.
- Familiarity with
parse_blocks()andserialize_blocks()— see the parse_blocks() reference.
Put everything in a small plugin, wp-tuts-hub-ai-translations. Do not put it in the theme: the workflow writes post meta and registers a CLI command, both of which outlive themes.
Step 1 — Store the glossary and version it
The glossary is the whole point. Without it, every model invents its own terminology per chunk, and a 4,000-word article ends up using three different words for the same feature. Store terms per locale plus a global list, and hash the glossary into the source hash later so a glossary edit invalidates old translations.
<?php
const WPTT_GLOSSARY_OPTION = 'wptt_translation_glossary';
function wptt_get_glossary( string $locale ): array {
$all = get_option( WPTT_GLOSSARY_OPTION, array() );
return array_merge(
$all['global'] ?? array(),
$all[ $locale ] ?? array()
);
}
/**
* Flatten a parsed block tree into translatable segments.
* Leaf blocks only; container blocks are recursed into.
*/
function wptt_collect_segments( array $blocks, string $prefix = '' ): array {
$segments = array();
foreach ( $blocks as $i => $block ) {
$path = $prefix . $i;
if ( ! empty( $block['innerBlocks'] ) ) {
$segments = array_merge(
$segments,
wptt_collect_segments( $block['innerBlocks'], $path . '.' )
);
continue;
}
$html = trim( $block['innerHTML'] ?? '' );
if ( '' === $html || ! wptt_is_translatable( $block['blockName'] ) ) {
continue;
}
$segments[] = array( 'id' => $path, 'html' => $html );
}
return $segments;
}
function wptt_is_translatable( ?string $name ): bool {
$allowed = array(
'core/paragraph', 'core/heading', 'core/list-item',
'core/quote', 'core/button', 'core/table',
);
return in_array( $name, apply_filters( 'wptt_translatable_blocks', $allowed ), true );
}
Two design decisions are buried in that snippet. First, segments are keyed by a dotted path (2.0.1) rather than by array order, so a model that returns segments out of order cannot shuffle your paragraphs. Second, only leaf blocks are sent. Container blocks carry no text of their own, and sending them invites the model to rewrite block markup it does not understand.
Step 2 — Chunk by block, not by character count
Naive chunking splits mid-sentence and produces translations that begin halfway through a clause. Because segments are already discrete blocks, chunking is just array_chunk( $segments, 25 ). Twenty-five blocks is roughly 600–900 words — large enough for the model to keep tone consistent, small enough to stay inside a sane timeout and to retry cheaply when one chunk fails.
Send the glossary with every chunk, not once at the start of a conversation. Stateless requests are easier to retry, and a failed chunk does not poison the ones after it.
Step 3 — Force a strict JSON schema
Prompt engineering is not a contract. A schema is. Define the response shape once and reuse it for every chunk so the parsing code has exactly one branch to handle.
{
"type": "object",
"required": ["locale", "segments"],
"additionalProperties": false,
"properties": {
"locale": { "type": "string" },
"segments": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "html"],
"additionalProperties": false,
"properties": {
"id": { "type": "string" },
"html": { "type": "string" }
}
}
},
"glossary_misses": {
"type": "array",
"items": { "type": "string" }
}
}
}
The glossary_misses array is the quiet hero. Ask the model to list any glossary term it could not apply naturally, and you get a free quality signal that costs nothing and tells a reviewer exactly where to look first.
Now the request itself. Note the explicit low temperature and the fact that the system prompt carries the glossary as a term list, not as prose instructions.
function wptt_request_translation( array $segments, string $locale, array $glossary ): array|WP_Error {
$system = sprintf(
"Translate WordPress block HTML into locale %s.\n" .
"Rules: keep inline tags (a, strong, em, code) intact; never translate text inside code tags; " .
"use these glossary terms exactly: %s.\n" .
"Return only JSON matching the schema. Preserve every segment id.",
$locale,
wp_json_encode( $glossary )
);
$response = wp_remote_post(
'https://api.example-llm.test/v1/chat/completions',
array(
'timeout' => 90,
'headers' => array(
'Authorization' => 'Bearer ' . WPTT_API_KEY,
'Content-Type' => 'application/json',
),
'body' => wp_json_encode( array(
'model' => 'translation-large',
'temperature' => 0.2,
'response_format' => array(
'type' => 'json_schema',
'json_schema' => array(
'name' => 'translation_batch',
'strict' => true,
'schema' => wptt_translation_schema(),
),
),
'messages' => array(
array( 'role' => 'system', 'content' => $system ),
array( 'role' => 'user', 'content' => wp_json_encode( array(
'locale' => $locale,
'segments' => $segments,
) ) ),
),
) ),
)
);
if ( is_wp_error( $response ) ) {
return $response;
}
$code = wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== $code || empty( $body['choices'][0]['message']['content'] ) ) {
return new WP_Error( 'wptt_llm_error', 'LLM call failed with HTTP ' . $code );
}
$decoded = json_decode( $body['choices'][0]['message']['content'], true );
if ( ! is_array( $decoded ) || empty( $decoded['segments'] ) ) {
return new WP_Error( 'wptt_bad_json', 'Model returned malformed JSON.' );
}
return $decoded;
}
See the wp_remote_post() reference for the full argument list; the important one here is timeout. Ninety seconds is generous, but translation endpoints are slower than chat endpoints and a 5-second default will fail every time.
Step 4 — Sanitise, rebuild, and guard against stale overwrites
This is where most homegrown pipelines break. A translation is generated from a snapshot of the source. If the editor updates the English post before the German draft is reviewed, the German draft is now describing an older article — and if you re-run the job, you silently destroy whatever the reviewer had already corrected.
The fix is a hash of everything that affects the output: source content, title, target locale, and the glossary itself. Store it on the translation. Compare before writing.
function wptt_source_hash( WP_Post $source, string $locale ): string {
return hash( 'sha256', implode( '|', array(
$source->post_content,
$source->post_title,
$locale,
wp_json_encode( wptt_get_glossary( $locale ) ),
) ) );
}
function wptt_apply_translation( WP_Post $source, string $locale, array $decoded ): int|WP_Error {
$map = array();
foreach ( $decoded['segments'] as $segment ) {
// Never trust model HTML.
$map[ (string) $segment['id'] ] = wp_kses_post( $segment['html'] );
}
$blocks = wptt_replace_segments( parse_blocks( $source->post_content ), $map );
$hash = wptt_source_hash( $source, $locale );
$existing = wptt_find_translation( $source->ID, $locale );
if ( $existing instanceof WP_Post ) {
$stored = get_post_meta( $existing->ID, '_wptt_source_hash', true );
if ( $stored && $stored !== $hash && ! wptt_overwrite_allowed() ) {
update_post_meta( $existing->ID, '_wptt_stale', '1' );
return new WP_Error( 'wptt_stale', 'Source changed; translation flagged stale.' );
}
$post_id = $existing->ID;
} else {
$post_id = wp_insert_post( array(
'post_type' => $source->post_type,
'post_status' => 'draft',
'post_title' => wp_strip_all_tags( $decoded['title'] ?? $source->post_title ),
'post_content' => serialize_blocks( $blocks ),
'post_author' => get_current_user_id(),
), true );
}
if ( is_wp_error( $post_id ) ) {
return $post_id;
}
update_post_meta( $post_id, '_wptt_source_hash', $hash );
update_post_meta( $post_id, '_wptt_source_id', $source->ID );
update_post_meta( $post_id, '_wptt_review_status', 'needs_review' );
return $post_id;
}
wp_kses_post() is doing real work here — read the allowed-tags reference so you know exactly what survives. If your blocks legitimately need <iframe> or <svg>, extend the allowed list explicitly rather than reaching for wp_kses_allowed_html( 'post' ) and hoping.
The stale flag is deliberately non-destructive. The reviewer sees “source changed since this draft was generated” and decides whether to regenerate or hand-merge. Regeneration is opt-in via wptt_overwrite_allowed(), which you should wire to a capability check plus an explicit CLI flag.
Step 5 — Link locales without hard-coding Polylang or WPML
Calling pll_save_post_translations() directly inside your pipeline means the day the client migrates to WPML, your plugin fatals. Wrap the behaviour in an interface and let implementations register themselves.
interface WPTT_Locale_Linker {
public function is_active(): bool;
public function link( int $translation_id, int $source_id, string $locale ): void;
}
class WPTT_Polylang_Linker implements WPTT_Locale_Linker {
public function is_active(): bool {
return function_exists( 'pll_set_post_language' );
}
public function link( int $translation_id, int $source_id, string $locale ): void {
pll_set_post_language( $translation_id, $locale );
pll_save_post_translations( array(
pll_get_post_language( $source_id ) => $source_id,
$locale => $translation_id,
) );
}
}
function wptt_link_translation( int $translation_id, int $source_id, string $locale ): void {
$linkers = apply_filters( 'wptt_locale_linkers', array( new WPTT_Polylang_Linker() ) );
foreach ( $linkers as $linker ) {
if ( $linker->is_active() ) {
$linker->link( $translation_id, $source_id, $locale );
return;
}
}
// Portable fallback: no plugin dependency at all.
$group = get_post_meta( $source_id, '_wptt_group', true ) ?: wp_generate_uuid4();
update_post_meta( $source_id, '_wptt_group', $group );
update_post_meta( $translation_id, '_wptt_group', $group );
update_post_meta( $translation_id, '_wptt_locale', $locale );
}
The fallback matters more than the adapter. A UUID in _wptt_group plus a locale string is enough to render a language switcher with WP_Query and a meta query, and it survives any plugin migration. Ship the fallback first, add adapters second.
Step 6 — Trigger from WP-CLI or the admin
CLI is the right default: it is scriptable, it batches, and it does not time out behind a web server. Register a command that accepts a post ID, a locale, and a dry-run flag.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'wptt translate', function ( array $args, array $assoc ) {
[ $post_id ] = $args;
$locale = $assoc['locale'] ?? '';
if ( ! $locale ) {
WP_CLI::error( 'Pass --locale=de_DE' );
}
$source = get_post( (int) $post_id );
if ( ! $source ) {
WP_CLI::error( 'Post not found.' );
}
$segments = wptt_collect_segments( parse_blocks( $source->post_content ) );
$merged = array( 'segments' => array() );
foreach ( array_chunk( $segments, 25 ) as $n => $chunk ) {
$result = wptt_request_translation( $chunk, $locale, wptt_get_glossary( $locale ) );
if ( is_wp_error( $result ) ) {
WP_CLI::warning( "Chunk {$n} failed: " . $result->get_error_message() );
continue;
}
$merged['segments'] = array_merge( $merged['segments'], $result['segments'] );
sleep( 2 );
}
if ( ! empty( $assoc['dry-run'] ) ) {
WP_CLI::log( wp_json_encode( $merged, JSON_PRETTY_PRINT ) );
return;
}
$new_id = wptt_apply_translation( $source, $locale, $merged );
if ( is_wp_error( $new_id ) ) {
WP_CLI::error( $new_id->get_error_message() );
}
wptt_link_translation( $new_id, $source->ID, $locale );
WP_CLI::success( "Draft #{$new_id} created and flagged for review." );
} );
}
Usage is then a two-liner, and the dry run is the single most useful debugging tool you will build:
wp wptt translate 412 --locale=de_DE --dry-run
wp wptt translate 412 --locale=de_DE
wp post list --post_status=draft --meta_key=_wptt_review_status --meta_value=needs_review
For an admin trigger, add a row action on the posts list that POSTs to admin-post.php with a nonce and a current_user_can( 'edit_post', $id ) check, then call the same three functions. Never let the browser call the LLM directly — the API key would be in the page source.
Step 7 — Make human review impossible to ignore
A draft with a meta flag nobody sees is a draft nobody reviews. Add a column to the posts list screen via manage_post_posts_columns and manage_post_posts_custom_column, rendering Needs review, Stale, or Reviewed from _wptt_review_status and _wptt_stale. Register both meta keys with register_post_meta() and 'show_in_rest' => true so the block editor’s document sidebar can surface them too.
Then close the loop: when a user with edit_post publishes the translation, flip _wptt_review_status to reviewed on transition_post_status. That single hook turns the flag into an audit trail and tells you which posts still need a human.
Troubleshooting
- Segments come back missing or duplicated. The model ignored IDs. Reject the chunk and retry once with the same schema; if it fails again, halve the chunk size. Never patch gaps by position.
- Translation is empty after insert.
wptt_replace_segments()probably matched oninnerContentrather thaninnerHTML. Dumpparse_blocks()output for one paragraph and compare. - Every run reports “stale”. Your glossary array ordering is unstable. Sort keys with
ksort()before hashing, or the hash changes on every request. - Polylang adapter never activates.
pll_set_post_language()is only defined after Polylang boots; run the link step oninitpriority 20 or later, not during plugin load. - Timeouts on long posts. Lower the chunk size to 15 and raise
timeoutto 120. If the endpoint still stalls, the bottleneck is output tokens, not input.
Next steps
Three extensions are worth building before you scale this to a full site. First, a glossary admin screen that writes to wptt_translation_glossary and bumps a version integer, so you can see which drafts predate a terminology change. Second, a batch command that walks wp post list --post_status=publish and queues one locale at a time with a rate limit. Third, a diff view in the review screen that shows the source segment beside the translated one — reviewers catch glossary drift far faster when the English sits next to the German.
Keep the pipeline boring: chunk, constrain, sanitise, hash, draft, flag, link. The interesting decisions belong to the human who clicks Publish.
Leave a Reply