Scale

Translating Structured Content and Product Catalogs

⏱ 17 min

Everything so far in this course translates one post at a time. A blog steadily accumulates a few dozen posts, but a WooCommerce catalog can hold hundreds of products, and a product is a mix of free-text content, a name and description, worth translating, and structured data, a price, a SKU, stock levels, taxonomy-based attributes, that should never be handed to a language model at all. This lesson builds a batch ability around wc_get_products() that translates the free-text fields and copies the structured ones across untouched.

What you'll learn in this lesson
Fetching a batch of products with wc_get_products()
Filtering by category, so a catalog with hundreds of products can be worked through in manageable batches.
Which product fields are safe to translate, and which aren't
Name and description are free text, price, SKU, and stock are structured data with no language of their own.
Why product attributes need their i18n plugin's own taxonomy translation, not an AI rewrite
Attribute terms are shared taxonomy data tied to variations, rewriting them per product would break that relationship.
Creating the translated product as a draft, linked the same way Lessons 2 and 3 already established
The batch job produces review-ready drafts, it doesn't publish a translated catalog automatically.
Prerequisites

WooCommerce active, with products assigned to at least one category for batching. Lesson 2’s WPML ability or Lesson 3’s Polylang ability, this lesson calls whichever linking function you already built rather than reintroducing it.

Step 1: Separate what’s free text from what’s structured data

Before writing any code, it’s worth being explicit about which fields this ability actually touches, since sending the wrong field to a translation prompt is the single most common mistake in catalog translation.

What gets translated, and what gets copied as-is
FieldTreatmentWhy
Name, short description, descriptionSent to the AI Client SDK for translationFree text, meaning depends on the reader’s language.
Regular price, sale priceCopied unchangedA number has no language, currency conversion and localized formatting are a separate, non-AI concern for a multicurrency plugin, not this ability.
SKU, stock quantity, stock statusCopied unchangedIdentifiers and inventory state, translating either would corrupt them.
Attribute terms (color, size, and similar)Left to your i18n plugin’s own taxonomy translationAttribute terms are shared taxonomy data tied to variations across the whole catalog, rewriting them per product breaks that shared relationship. WPML and Polylang both have dedicated taxonomy translation features for exactly this, use those, not a per-product AI rewrite.

Step 2: Register the batch ability

// File: catalog-translation/catalog-translation.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'catalog-translation/translate-category',
		array(
			'label'               => __( 'Batch-translate a product category', 'catalog-translation' ),
			'description'         => __( 'Generates translated drafts for every published product in a category, translating only name and description fields. Price, SKU, and stock are copied unchanged. Always creates drafts, never publishes.', 'catalog-translation' ),
			'category'            => 'store-inventory',
			'input_schema'        => array(
				'type'       => 'object',
				'properties' => array(
					'category_slug'   => array( 'type' => 'string' ),
					'target_language' => array( 'type' => 'string' ),
				),
				'required'   => array( 'category_slug', 'target_language' ),
			),
			'output_schema'       => array(
				'type'       => 'object',
				'properties' => array(
					'translated' => array( 'type' => 'integer' ),
					'skipped'    => array( 'type' => 'integer' ),
				),
			),
			'permission_callback' => function () {
				return current_user_can( 'edit_products' );
			},
			'execute_callback'    => 'catalog_translation_translate_category',
			'meta' => array(
				'annotations' => array( 'destructive' => false, 'idempotent' => false ),
				'mcp'         => array( 'public' => true ),
			),
		)
	);
} );

Step 3: Fetch the batch, translate only the free-text fields

// File: catalog-translation/catalog-translation.php
function catalog_translation_translate_category( $input ) {
	$target_language = sanitize_text_field( $input['target_language'] );

	$products = wc_get_products( array(
		'category' => array( sanitize_text_field( $input['category_slug'] ) ),
		'status'   => 'publish',
		'limit'    => -1,
	) );

	$translated = 0;
	$skipped    = 0;

	foreach ( $products as $product ) {
		$result = catalog_translation_translate_one_product( $product, $target_language );
		if ( $result ) {
			$translated++;
		} else {
			$skipped++;
		}
	}

	return array( 'translated' => $translated, 'skipped' => $skipped );
}

function catalog_translation_translate_one_product( $product, $target_language ) {
	$prompt = <<<PROMPT
Translate only the product name and description below into the language identified by the code
"{$target_language}". Do not translate or alter any numbers, currency symbols, or SKU-like codes
you see, none appear in this input, but treat any that do as literal text to leave unchanged.

Name: {$product->get_name()}
Short description: {$product->get_short_description()}
Description: {$product->get_description()}

Return three lines prefixed exactly: "Name: ", "Short: ", "Description: ".
PROMPT;

	$response = wp_ai_client_prompt( $prompt )->generate_text();

	if ( empty( $response ) ) {
		return false;
	}

	$fields = catalog_translation_parse_fields( $response );
	if ( ! $fields ) {
		return false;
	}

	$new_product = new WC_Product_Simple();
	$new_product->set_name( $fields['name'] );
	$new_product->set_short_description( $fields['short'] );
	$new_product->set_description( $fields['description'] );

	// Structured fields copied unchanged, never sent to the model.
	$new_product->set_regular_price( $product->get_regular_price() );
	$new_product->set_sale_price( $product->get_sale_price() );
	$new_product->set_sku( $product->get_sku() . '-' . $target_language );
	$new_product->set_manage_stock( $product->get_manage_stock() );
	$new_product->set_stock_quantity( $product->get_stock_quantity() );
	$new_product->set_status( 'draft' ); // Never auto-publish a translated product.

	$new_product_id = $new_product->save();

	if ( ! $new_product_id ) {
		return false;
	}

	// Link into WPML or Polylang using whichever ability's linking function you built in
	// Lesson 2 or Lesson 3, for example:
	// wpml_translation_pipeline_link( $product->get_id(), $new_product_id, $target_language );

	return true;
}

function catalog_translation_parse_fields( $response ) {
	if ( preg_match( '/^Name:\s*(.+)$/mi', $response, $name )
		&& preg_match( '/^Short:\s*(.+)$/mi', $response, $short )
		&& preg_match( '/^Description:\s*(.+)$/mi', $response, $desc ) ) {
		return array(
			'name'        => trim( $name[1] ),
			'short'       => trim( $short[1] ),
			'description' => trim( $desc[1] ),
		);
	}
	return null;
}
Why price, SKU, and stock never touch the prompt
1
A number has no language to translate into
Sending a price into a translation prompt risks the model reformatting or, worse, converting it, neither is its job here.
2
SKUs are identifiers, not content
The SKU gets a language suffix appended in code, deliberately, not passed through the model at all.
3
Stock levels reflect real-time inventory, not translatable text
Copying stock_quantity and manage_stock directly keeps the translated draft's inventory state accurate at the moment of creation.

Test it

wp-env run cli wp eval '
$result = wp_get_ability( "catalog-translation/translate-category" )->execute( array(
	"category_slug"   => "outdoor-gear",
	"target_language" => "es",
) );
print_r( $result );
'

Spot-check a handful of the resulting draft products in wp-admin. Confirm the name and description read naturally in the target language, and separately confirm the price, SKU suffix, and stock quantity match the source product exactly, those fields should never look “translated” in any way.

Running this against your entire catalog in one call

wc_get_products() with limit => -1 on a large catalog can mean hundreds of sequential AI calls in one request, the same execution-time risk Course 6’s Bulk-Editing Assistant project warned about for regular posts. For a real store, batch by category and run smaller batches, or move this into a scheduled background job, rather than one enormous synchronous call across an entire catalog.

Recap

wc_get_products() fetches a category’s worth of products in one call, and each product is split into two clearly separate treatments: free-text fields (name, short description, description) go through wp_ai_client_prompt() for translation, while price, SKU, and stock are copied directly into the new product object without ever reaching the model. Attribute terms are deliberately left out entirely, since they’re shared taxonomy data your i18n plugin’s own translation feature already handles correctly. Every translated product is created as a draft, same rule as every other ability in this course.

Resources & further reading

← Locale-Aware SEO and hreflang Human Review Gates for Translation Quality →