Delta translation memory: re-translating only the paragraph you changed in WordPress

How our WordPress translator segments Gutenberg blocks with masked inline markup, keys a translation memory on the masked text, and after an edit sends exactly one segment per language while human edits stay protected.

DSDilawar Singh 10 min read 3 views
Delta translation memory: re-translating only the paragraph you changed in WordPress

The first translation of a page is the easy part. The hard part is everything after it. An editor fixes a typo in paragraph seven, a reviewer corrects the German wording of the call to action, and the marketing team adds a sentence to the intro. A naive integration sends the whole page to the AI again for every language. That costs tokens, adds latency, and overwrites the reviewer's correction with fresh machine output. When we built DEV3 AI Translator & Cultural Localizer, our WordPress plugin for Polylang, WPML and TranslatePress, we wanted the opposite: after an edit, only the paragraph that changed goes to the AI, and nothing a human touched is ever overwritten. This article explains the two pieces that make that work, a block-aware segmenter and a content-addressed translation memory, and the tests that hold them to it.

A page is a template plus segments

Delta sync only works if "the paragraph that changed" is a well-defined, stable unit. In WordPress that is harder than it sounds. A Gutenberg post is HTML interleaved with block comments such as <!-- wp:paragraph -->, some of which carry JSON attributes. Inside a paragraph there are links, bold text, line breaks and shortcodes. Classic content has no block comments at all and relies on blank lines between paragraphs.

The plugin turns every field (title, content, excerpt, SEO fields, custom fields) into a Document. A document holds two things:

  • Field templates. Each template is a list of pieces: literal strings, kept verbatim, and references to segments.
  • Segments. Each segment is one unit of translatable text, with a stable id, a kind (html, text or attr), a context hint and a hash.

The invariant is stated at the top of Document.php: "rendering a template with no translations returns the original value byte for byte." Everything that is not translatable stays literal and cannot be damaged. That includes block delimiters, tag attributes, URLs, <code> and <pre>, SVG, and anything marked translate="no" or notranslate.

Inline tags become numbered placeholders

The segmenter walks the token stream from Html_Tokenizer. Block-level tags and block comments end the current text run. Inline tags (a, strong, em, br, span and similar) stay inside it. When a run ends, flush() trims edge whitespace and wrapper tags that enclose the whole run, so <a>Buy now</a> becomes just "Buy now". It then replaces each inline tag pair with a numbered placeholder and remembers the original markup (condensed):

foreach ( $core as $i => $item ) {
	$raw .= $item['raw'];
	if ( 'text' === $item['type'] ) {
		$mask .= $item['raw'];
		continue;
	}
	if ( 'tag' === $item['type'] && $item['closing'] && isset( $pairs[ $i ] ) ) {
		$k                   = $number[ $pairs[ $i ] ];
		$tags[ $k ]['close'] = $item['raw'];
		$mask               .= '</' . $k . '>';
		continue;
	}
	++$n;
	$number[ $i ] = $n;
	$tags[ $n ]   = array(
		'open'  => $this->tag_pieces( $item, $context['tag'] ),
		'close' => null,
	);
	$mask        .= isset( $pairs[ $i ] ) ? '<' . $n . '>' : '<' . $n . '/>';
}

So <p>Second paragraph with a <a href="/x">link</a>.</p> produces one segment whose source is Second paragraph with a <1>link</1>.. The AI never sees the href, a class name or a tracking parameter, and it cannot rewrite them. On the way back, the renderer swaps <1>…</1> for the stored opening and closing tags. Self-closing tags such as <br> become <2/>.

Three more cases matter in practice:

  • Attributes. alt, title, placeholder, aria-label and the value of submit buttons become their own attr segments. They are spliced back into the tag at their original offset, so the other attributes stay byte-identical. A filter, aitcl_translate_html_attribute, lets developers add more.
  • Block attributes. Some blocks keep visible text in their JSON comment, for example a button's text or an FAQ block's question and answer. The segmenter decodes the JSON, walks it for a whitelist of keys (filterable through aitcl_block_attribute_keys), and makes each string value a segment. On the way out, the renderer writes translated values back at the same paths and re-encodes the comment with WordPress' own serialize_block_attributes(). A block whose strings did not change is written back untouched.
  • Plain-text fields. Titles and SEO fields go through text(), which masks template variables. Shortcodes, %%title%%-style SEO variables, {{mustache}} and printf placeholders become <n/> so they survive translation untouched.

The context stored with each segment ("p in core/paragraph", "img@alt", "core/button.text") is sent to the model, so it knows that a two-word string is a button label rather than a heading.

The translation memory is content-addressed

With stable segments, the translation memory is a single table keyed by a hash of the masked source and the locale pair:

public static function normalize( $text ) {
	$text = preg_replace( '/\s+/u', ' ', trim( (string) $text ) );
	return null === $text ? trim( (string) $text ) : $text;
}

public static function hash( $text ) {
	return sha1( self::normalize( $text ) );
}

Two design choices fall out of hashing the masked source:

  1. Markup-only edits do not trigger re-translation. Changing a link's URL, adding a CSS class or moving a block changes the literal pieces, not the segment source. The hash stays the same, the memory hits, and the new markup is rendered around the existing translation.
  2. Reuse works across posts. The same footer sentence, product disclaimer or button label in fifty posts is translated once per language.

Whitespace normalization means reflowed HTML (a line break added by an editor, or indentation from a page builder) does not count as a change either.

Resolving a sync: reuse first, send the rest

Sync_Engine::resolve() is the whole delta algorithm. It looks up every segment hash in one batched query, reuses hits, and collects the rest (condensed):

$hits = Translation_Memory::lookup( array_column( $doc->segments, 'hash' ), $source_locale, $target_locale );
foreach ( $doc->segments as $id => $segment ) {
	$hit = isset( $hits[ $segment['hash'] ] ) ? $hits[ $segment['hash'] ] : null;
	if ( $hit && ( ! $opts['force'] || in_array( $hit['status'], array( 'human', 'approved' ), true ) ) ) {
		$translations[ $id ] = $hit['translation'];
		$status[ $id ]       = $hit['status'];
		$reused_ids[]        = (int) $hit['id'];
		continue;
	}
	// Identical source text inside one document is sent once.
	if ( ! isset( $unique_send[ $segment['hash'] ] ) ) {
		$unique_send[ $segment['hash'] ] = $id;
		$send[ $id ]                     = $segment;
	}
}

Only $send goes to the Translator, which batches segments (40 items or 6,000 characters per request by default) under a structured-output schema with short ids. After the answer comes back and is written to the memory, the engine re-reads the memory for the sent hashes rather than using the answer directly. That way a human edit that was saved during the AI call wins, and duplicate segments inside the document all get the same text. The document is then rendered and handed to the provider: a linked translation post for Polylang and WPML, updated in place, or dictionary strings for TranslatePress.

The force option shows the priority order. A forced re-translation ignores machine entries but still reuses human and approved ones. The memory enforces the same rule at write time:

if ( 'human' === $current['status'] && in_array( $status, array( 'machine', 'review' ), true ) ) {
	return false;
}

There is one more layer. A translation post edited directly in the WordPress editor, outside the plugin's review screen, would be silently overwritten by the next sync. Post_Writer stores an md5 fingerprint of title, content and excerpt each time it writes a translation. If the fingerprint no longer matches, the sync reports "edited manually" and skips that post unless the user chooses to overwrite it.

Auto-sync: the edit triggers the delta

Delta sync is cheap enough to run on every save. Auto_Sync hooks save_post at priority 99. If the saved post already has translations (per-language stats are stored on the source post), it queues one background sync job for those languages. Guards skip revisions, autosaves, auto-drafts, trash, posts that already have a pending job, and the plugin's own writes, so saving a translation does not trigger another sync.

In the browser run on Polylang, editing one paragraph of a translated post produced an auto-sync job to German and French that reported "4 of 6 segments reused". The post had 3 segments, the title and two paragraphs, in each of 2 languages. Only the edited paragraph was sent, once per language.

What the tests pin down

DeltaSyncTest runs against the real Polylang adapter, with the Google Gemini API mocked at the HTTP layer through pre_http_request. The fixture is a block post with three blocks: a paragraph, a paragraph with a link, and a heading. The first sync sends all 4 segments (title plus 3 blocks) in one batched request and creates a linked German post. Then the test edits the middle paragraph and syncs again (condensed):

wp_update_post( array(
	'ID'           => $post,
	'post_content' => str_replace( 'Second paragraph with a', 'Second paragraph, now edited, with a', self::CONTENT ),
) );
$this->mock->requests = array();
$second               = Sync_Engine::sync( $post, 'de' );

$this->assertSame( 4, $second['total'] );
$this->assertSame( 3, $second['reused'] );
$this->assertSame( 1, $second['sent'] );
$this->assertSame( 'Second paragraph, now edited, with a <1>link</1>.', $this->mock->items()[0]['text'] );
$this->assertSame( $target, $second['target_id'] ); // Updated in place.

// Nothing changed → no request at all.
$this->mock->requests = array();
$third                = Sync_Engine::sync( $post, 'de' );
$this->assertSame( 0, $third['sent'] );
$this->assertCount( 0, $this->mock->requests );

The assertions cover the claim end to end. Exactly one segment is sent, and it is the edited one with its link masked as a placeholder. The translation post is updated in place rather than duplicated, the unchanged paragraphs keep their earlier translations, and a sync with no changes makes no HTTP request at all. Sibling tests check that a human-edited segment survives both a delta sync and a forced re-translation (3 of 4 segments sent, never the human one), and that the review screen rejects a manual edit that drops a placeholder. The full suite is 33 PHPUnit tests with 339 assertions, plus 9 Jest tests for the admin utilities.

Checking answers before they enter the memory

A memory is only as good as what goes into it, so every AI answer is checked before it is saved. Translator::check() compares the placeholders in the source and the translation. They must be exactly the same set, because a dropped </1> would break the rendered HTML. It also applies the glossary rules: do-not-translate terms must appear unchanged, and forced translations must be used. Segments with problems get one corrective retry, with the specific issues listed in the prompt. If a segment still fails, the best attempt is repaired so the markup stays valid, saved with status review, and shown in the review screen. It is never silently published as clean machine output.

Cultural adaptation works within the same structure. Each target locale has a profile covering formality, unit system, date and number formats, currency handling, idiom adaptation, brand voice and custom instructions. Unit_Converter detects measurements in the segment's plain text and attaches pre-computed conversions, so the model copies numbers instead of calculating them. In the live run with gemini-2.5-pro from English to German and French, "6 feet" became 1,83 m and "300 lbs" became 136 kg. The glossary brand name stayed as written, both languages used formal address (Sie, vous), an English idiom was replaced with a natural equivalent, and a re-sync afterwards reused 6 of 6 segments.

Trade-offs

  • Hash equality is strict. Fixing a comma creates a new hash, so that segment is re-translated. We accept this: the alternative, fuzzy matching, would reuse translations that no longer match their source. The cost is one segment, not one page.
  • Context-free reuse. The same English sentence in two posts gets the same translation. That is usually what you want, and the context hint makes collisions rare in practice. A reviewer can still edit one occurrence through the post's translation if needed.
  • WPML is tested through hook stubs. Polylang runs for real in PHPUnit and in the browser, and TranslatePress was verified in the browser. WPML is a commercial plugin, so its provider is written against WPML's public hooks (wpml_element_trid, wpml_set_element_language_details and others) and tested with stubs of them.
  • TranslatePress has no translation posts. It stores rendered strings, so the TranslatePress provider renders the post the way the front end does, lets TranslatePress' own parser list the strings, and segments each one. The memory and the delta logic are the same; only the write path differs.

Conclusion

Delta translation comes down to choosing the right unit. A block-aware segmenter makes the paragraph, the attribute and the block-attribute string the unit, with markup masked out. A content-addressed memory keyed on that masked text makes "what changed" a hash comparison. A small set of status rules (human beats approved beats machine, re-read after write, fingerprint the post) keeps reviewers' work safe from the next sync. The plugin is being prepared for release on CodeCanyon and the DEV3 marketplace. The case study covers the full architecture.

DS

Written by

Dilawar Singh

CTO, DEV3 Technologies · ClinicPro, Inventra & WorkPro

Dilawar Singh is the CTO of DEV3 Technologies. He leads the engineering of DEV3’s own products — ClinicPro, Inventra and WorkPro — which are built, sold and supported by the DEV3 team from Mohali, India.

Keep reading

All articles