WPML Translation Workflow — Claude Code + Vault
Goal: Use Claude Code to translate baseworks.com content into multiple languages via WPML, with translation files stored in the Obsidian vault as the source of truth.
Approach: Keep WPML (lifetime deal). Add a small mu-plugin (~100 lines) that exposes REST endpoints for creating and linking WPML translations programmatically. Claude Code drafts translations, stores them in the vault, and pushes them to WordPress through these endpoints.
Priority languages: French, Spanish (first), then Russian, Chinese, German (later).
1. How It Works
Section titled “1. How It Works”1. Claude Code reads English page content (via WP REST API)2. Claude Code writes the translation (stored as JSON in the vault)3. Claude Code pushes the translation to WordPress (via custom REST endpoint)4. WPML links the translated post to the English original5. WPML handles hreflang, language switcher, URL routing — all its normal jobsWordPress and WPML handle everything they’re already good at (routing, hreflang, language switcher, WooCommerce). Claude Code handles the part that’s currently manual (writing and deploying translations).
2. Technical Setup
Section titled “2. Technical Setup”2.1 Enable WPML languages
Section titled “2.1 Enable WPML languages”In WP Admin → WPML → Languages, add:
- English (already active)
- Japanese (already active)
- French (fr)
- Spanish (es)
- Russian (ru) — later
- Chinese Simplified (zh-hans) — later
- German (de) — later
2.2 mu-plugin: WPML Translation API
Section titled “2.2 mu-plugin: WPML Translation API”A small mu-plugin that adds custom REST endpoints for Claude Code to create and manage translations without needing WP admin access.
File: wp-content/mu-plugins/bw-wpml-translation-api.php
<?php/** * Baseworks WPML Translation API * * Adds REST endpoints for programmatic WPML translation management. * Used by Claude Code to create/update translations from the vault. */
add_action('rest_api_init', function () {
// POST /wp-json/bw/v1/translate // Creates or updates a translation for a given post register_rest_route('bw/v1', '/translate', [ 'methods' => 'POST', 'callback' => 'bw_wpml_create_translation', 'permission_callback' => function () { return current_user_can('edit_posts'); }, ]);
// GET /wp-json/bw/v1/translation-status // Returns translation status for a post across all languages register_rest_route('bw/v1', '/translation-status/(?P<post_id>\d+)', [ 'methods' => 'GET', 'callback' => 'bw_wpml_translation_status', 'permission_callback' => function () { return current_user_can('read'); }, ]);
// GET /wp-json/bw/v1/untranslated // Returns posts that lack translations in a given language register_rest_route('bw/v1', '/untranslated/(?P<lang>[a-z\-]+)', [ 'methods' => 'GET', 'callback' => 'bw_wpml_untranslated', 'permission_callback' => function () { return current_user_can('read'); }, ]);});
function bw_wpml_create_translation($request) { $source_post_id = (int) $request->get_param('source_post_id'); $target_lang = sanitize_text_field($request->get_param('language')); $translated_title = $request->get_param('title'); $translated_body = $request->get_param('content'); $translated_slug = sanitize_title($request->get_param('slug') ?: $translated_title);
if (!$source_post_id || !$target_lang || !$translated_title) { return new WP_Error('missing_params', 'source_post_id, language, and title are required.', ['status' => 400]); }
$source_post = get_post($source_post_id); if (!$source_post) { return new WP_Error('not_found', 'Source post not found.', ['status' => 404]); }
// Get the source post's WPML element type and trid $element_type = 'post_' . $source_post->post_type; $source_lang_details = apply_filters('wpml_element_language_details', null, [ 'element_id' => $source_post_id, 'element_type' => $source_post->post_type, ]);
if (!$source_lang_details || !isset($source_lang_details->trid)) { return new WP_Error('wpml_error', 'Could not get WPML language details for source post.', ['status' => 500]); }
$trid = $source_lang_details->trid; $source_lang = $source_lang_details->language_code;
// Check if a translation already exists for this language $existing_translations = apply_filters('wpml_get_element_translations', null, $trid, $element_type); $existing_translation_id = null;
if (!empty($existing_translations[$target_lang])) { $existing_translation_id = $existing_translations[$target_lang]->element_id; }
// Create or update the translated post $post_data = [ 'post_title' => $translated_title, 'post_content' => $translated_body ?: '', 'post_status' => $source_post->post_status, 'post_type' => $source_post->post_type, 'post_name' => $translated_slug, ];
if ($existing_translation_id) { // Update existing translation $post_data['ID'] = $existing_translation_id; $result_id = wp_update_post($post_data, true); } else { // Create new translation $result_id = wp_insert_post($post_data, true); }
if (is_wp_error($result_id)) { return $result_id; }
// Link the translation to the source post via WPML do_action('wpml_set_element_language_details', [ 'element_id' => $result_id, 'element_type' => $element_type, 'trid' => $trid, 'language_code' => $target_lang, 'source_language_code' => $source_lang, ]);
// Copy SEOPress meta if the source has it $seo_fields = ['_seopress_titles_title', '_seopress_titles_desc', '_seopress_social_fb_title', '_seopress_social_fb_desc', '_seopress_social_twitter_title', '_seopress_social_twitter_desc']; $translated_meta = $request->get_param('meta') ?: []; foreach ($seo_fields as $field) { if (isset($translated_meta[$field])) { update_post_meta($result_id, $field, sanitize_text_field($translated_meta[$field])); } }
return [ 'success' => true, 'translated_post_id' => $result_id, 'source_post_id' => $source_post_id, 'language' => $target_lang, 'trid' => $trid, 'url' => get_permalink($result_id), 'action' => $existing_translation_id ? 'updated' : 'created', ];}
function bw_wpml_translation_status($request) { $post_id = (int) $request->get_param('post_id'); $post = get_post($post_id);
if (!$post) { return new WP_Error('not_found', 'Post not found.', ['status' => 404]); }
$element_type = 'post_' . $post->post_type; $lang_details = apply_filters('wpml_element_language_details', null, [ 'element_id' => $post_id, 'element_type' => $post->post_type, ]);
if (!$lang_details) { return new WP_Error('wpml_error', 'Could not get WPML language details.', ['status' => 500]); }
$translations = apply_filters('wpml_get_element_translations', null, $lang_details->trid, $element_type);
$active_languages = apply_filters('wpml_active_languages', null, []); $status = [];
foreach ($active_languages as $lang_code => $lang_info) { $status[$lang_code] = [ 'language' => $lang_info['translated_name'], 'translated' => isset($translations[$lang_code]), 'post_id' => isset($translations[$lang_code]) ? $translations[$lang_code]->element_id : null, 'url' => isset($translations[$lang_code]) ? get_permalink($translations[$lang_code]->element_id) : null, ]; }
return [ 'source_post_id' => $post_id, 'source_lang' => $lang_details->language_code, 'trid' => $lang_details->trid, 'translations' => $status, ];}
function bw_wpml_untranslated($request) { $target_lang = sanitize_text_field($request->get_param('lang')); $post_type = sanitize_text_field($request->get_param('post_type') ?: 'page'); $per_page = (int) ($request->get_param('per_page') ?: 50);
global $wpdb;
// Get posts in the default language that lack a translation in the target language $results = $wpdb->get_results($wpdb->prepare(" SELECT p.ID, p.post_title, p.post_type, t.trid FROM {$wpdb->posts} p INNER JOIN {$wpdb->prefix}icl_translations t ON t.element_id = p.ID AND t.element_type = CONCAT('post_', p.post_type) AND t.language_code = ( SELECT default_locale FROM {$wpdb->prefix}icl_languages WHERE code = t.language_code LIMIT 1 ) WHERE p.post_status = 'publish' AND p.post_type = %s AND t.source_language_code IS NULL AND t.trid NOT IN ( SELECT trid FROM {$wpdb->prefix}icl_translations WHERE language_code = %s ) ORDER BY p.post_title ASC LIMIT %d ", $post_type, $target_lang, $per_page));
// Simplified fallback if the complex query fails if ($wpdb->last_error) { // Fallback: get all published posts and filter in PHP $all_posts = get_posts([ 'post_type' => $post_type, 'post_status' => 'publish', 'numberposts' => $per_page, 'suppress_filters' => false, ]);
$results = []; foreach ($all_posts as $post) { $lang_details = apply_filters('wpml_element_language_details', null, [ 'element_id' => $post->ID, 'element_type' => $post->post_type, ]);
if ($lang_details && $lang_details->source_language_code === null) { $translations = apply_filters('wpml_get_element_translations', null, $lang_details->trid, 'post_' . $post->post_type); if (!isset($translations[$target_lang])) { $results[] = (object) [ 'ID' => $post->ID, 'post_title' => $post->post_title, 'post_type' => $post->post_type, 'trid' => $lang_details->trid, ]; } } } }
return [ 'language' => $target_lang, 'post_type' => $post_type, 'count' => count($results), 'untranslated' => array_map(function ($r) { return [ 'post_id' => $r->ID, 'title' => $r->post_title, 'post_type' => $r->post_type, 'trid' => $r->trid, 'edit_url' => admin_url("post.php?post={$r->ID}&action=edit"), ]; }, $results), ];}2.3 Authentication
Section titled “2.3 Authentication”Claude Code authenticates to the REST API via application passwords (built into WordPress 5.6+):
- In WP Admin → Users → Patrick’s profile → Application Passwords
- Create a new application password named “Claude Code”
- Store the credentials securely (e.g., in
~/.baseworks-api-credentialswithchmod 600)
BASEWORKS_WP_USER="patrick"BASEWORKS_WP_APP_PASSWORD="xxxx xxxx xxxx xxxx xxxx xxxx"BASEWORKS_WP_URL="https://baseworks.com"3. Translation Workflow — Step by Step
Section titled “3. Translation Workflow — Step by Step”3.1 Discover what needs translating
Section titled “3.1 Discover what needs translating”# Claude Code runs this to find untranslated pages for Frenchcurl -s -u "$BASEWORKS_WP_USER:$BASEWORKS_WP_APP_PASSWORD" \ "$BASEWORKS_WP_URL/wp-json/bw/v1/untranslated/fr?post_type=page" | jq .Returns a list of English pages with no French translation.
3.2 Read the English source content
Section titled “3.2 Read the English source content”# Get the full content of a specific pagecurl -s -u "$BASEWORKS_WP_USER:$BASEWORKS_WP_APP_PASSWORD" \ "$BASEWORKS_WP_URL/wp-json/wp/v2/pages/12345" | jq '{title: .title.rendered, content: .content.rendered, slug: .slug}'3.3 Claude Code writes the translation
Section titled “3.3 Claude Code writes the translation”Claude Code reads the English content, writes the French translation, and saves it in the vault:
Vault file: 03-resources/translations/fr/pages/about.json
{ "_meta": { "source_post_id": 12345, "source_url": "/about/", "language": "fr", "translated_by": "claude-code", "translated_date": "2026-03-10", "status": "draft" }, "title": "À propos de Baseworks", "slug": "a-propos", "content": "<p>Baseworks fournit une approche structurée de l'éducation physique...</p>", "seo": { "_seopress_titles_title": "À propos de Baseworks | Éducation physique", "_seopress_titles_desc": "Baseworks est un cadre d'éducation physique fondé sur..." }}3.4 Review the translation
Section titled “3.4 Review the translation”Two options:
- In the vault: Patrick or Asia reads the JSON file and edits if needed
- In SimpleLocalize (optional): If you later add SimpleLocalize, translations can be reviewed in its web UI before deploying
After review, update status to "approved".
3.5 Push the translation to WordPress
Section titled “3.5 Push the translation to WordPress”# Claude Code pushes the approved translation to WPMLcurl -s -X POST \ -u "$BASEWORKS_WP_USER:$BASEWORKS_WP_APP_PASSWORD" \ "$BASEWORKS_WP_URL/wp-json/bw/v1/translate" \ -H "Content-Type: application/json" \ -d '{ "source_post_id": 12345, "language": "fr", "title": "À propos de Baseworks", "slug": "a-propos", "content": "<p>Baseworks fournit une approche structurée...</p>", "meta": { "_seopress_titles_title": "À propos de Baseworks | Éducation physique", "_seopress_titles_desc": "Baseworks est un cadre d'éducation physique fondé sur..." } }'WPML automatically:
- Creates the
/fr/a-propos/URL - Links it as the French translation of
/about/ - Adds hreflang tags to both pages
- Includes it in the language switcher
- Adds it to the multilingual sitemap
3.6 Verify
Section titled “3.6 Verify”# Check translation statuscurl -s -u "$BASEWORKS_WP_USER:$BASEWORKS_WP_APP_PASSWORD" \ "$BASEWORKS_WP_URL/wp-json/bw/v1/translation-status/12345" | jq .4. Handling Different Content Types
Section titled “4. Handling Different Content Types”4.1 Regular pages and posts
Section titled “4.1 Regular pages and posts”Straightforward — the workflow above covers these. Claude Code reads wp/v2/pages/{id} or wp/v2/posts/{id}, translates the content, pushes via bw/v1/translate.
4.2 Elementor pages
Section titled “4.2 Elementor pages”Elementor stores its layout data in postmeta (_elementor_data). WPML handles Elementor translation by:
- Duplicating the Elementor page structure
- Registering each text widget/element as a translatable string
For Claude Code: Two approaches:
Approach A: Use WPML’s String Translation (recommended for Elementor)
- WPML’s Translation Editor shows each Elementor text element as a separate string
- Claude Code translates each string via the WPML String Translation API
- The Elementor layout stays intact; only text changes
Approach B: Duplicate and edit via REST API
- Use
bw/v1/translateto create the translated post - Copy the source
_elementor_datapostmeta to the translated post - Use a second REST call to update
_elementor_datawith translated text nodes
Approach A is cleaner and what WPML is designed for. We can add a bw/v1/translate-strings endpoint to handle string-level translation for Elementor content.
4.3 WooCommerce products
Section titled “4.3 WooCommerce products”WooCommerce products are a custom post type (product). The mu-plugin handles them the same way — post_type is just product instead of page.
# Translate a WooCommerce productcurl -s -X POST \ -u "$BASEWORKS_WP_USER:$BASEWORKS_WP_APP_PASSWORD" \ "$BASEWORKS_WP_URL/wp-json/bw/v1/translate" \ -d '{ "source_post_id": 67890, "language": "fr", "title": "Sessions de pratique — Introduction", "content": "<p>Votre première session d'introduction...</p>", "slug": "sessions-de-pratique-introduction" }'Product attributes and variations: These need additional handling. WPML manages them through its WooCommerce Multilingual add-on. If WCML is part of the lifetime deal, it handles:
- Product attribute translations
- Variation descriptions
- Category and tag translations
- Translated product slugs
- Cart/checkout string translations
4.4 Menus and widgets
Section titled “4.4 Menus and widgets”WPML manages menu translations through its own interface. Claude Code can assist by:
- Reading the English menu structure via
wp/v2/menus - Drafting translated menu item labels
- Storing them in the vault for reference
Menu translation deployment may still need WP admin for initial setup, but subsequent text updates can go through string translation.
4.5 Theme and plugin strings
Section titled “4.5 Theme and plugin strings”WPML String Translation handles these. Claude Code can:
- List untranslated strings via
wp evalover SSH - Translate them
- Push translations via
wp evalwith WPML’sicl_add_string_translation()function
# Via WP-CLI over SSHssh baseworks-web "cd /var/www/baseworks.com && \ wp eval ' icl_add_string_translation( icl_get_string_id(\"Add to Cart\", \"WooCommerce\"), \"fr\", \"Ajouter au panier\", ICL_TM_COMPLETE ); ' 2>/dev/null"4.6 Policy/legal pages
Section titled “4.6 Policy/legal pages”These already exist as standalone French pages in WordPress. During setup:
- Convert them to proper WPML translations (link them to the English source via the mu-plugin)
- Future updates go through the standard vault → translate → push workflow
- Termageddon-managed English pages: the French translation is separate content, not a Termageddon embed
5. Vault File Structure
Section titled “5. Vault File Structure”03-resources/translations/├── README.md # This workflow documentation├── config/│ └── languages.yml # Active languages and their status├── fr/│ ├── pages/│ │ ├── about.json│ │ ├── programs.json│ │ ├── practice-sessions.json│ │ ├── study-group.json│ │ └── ...│ ├── products/│ │ ├── practice-session-intro.json│ │ └── ...│ ├── legal/│ │ ├── privacy-policy.json│ │ ├── cookie-policy.json│ │ └── ...│ └── strings/│ ├── theme.json # Kadence theme strings│ ├── woocommerce.json # WooCommerce UI strings│ └── seopress.json # SEO metadata├── es/│ └── (same structure)├── ja/│ └── (same structure — migrated from current WPML)└── (ru/, zh-hans/, de/ — added later)Translation file format
Section titled “Translation file format”Each file follows this structure:
{ "_meta": { "source_post_id": 12345, "source_url": "/about/", "language": "fr", "translated_by": "claude-code", "translated_date": "2026-03-10", "reviewed_by": null, "reviewed_date": null, "status": "draft", "wpml_post_id": null, "notes": "Initial translation" }, "title": "À propos de Baseworks", "slug": "a-propos", "content": "...", "seo": { "_seopress_titles_title": "...", "_seopress_titles_desc": "..." }}Status values: draft → approved → deployed
After successful push to WordPress, Claude Code updates status to deployed and records wpml_post_id.
6. Session Flow — Typical Translation Session
Section titled “6. Session Flow — Typical Translation Session”Example: “Translate the About page into French and Spanish”
Section titled “Example: “Translate the About page into French and Spanish””Step 1: Claude Code checks what's untranslated → GET /bw/v1/untranslated/fr → GET /bw/v1/untranslated/es
Step 2: Claude Code reads the English About page → GET /wp/v2/pages/12345
Step 3: Claude Code reads the voice guides (for Baseworks tone) → Reads 03-resources/voice-guides/VOICE-GUIDE-UNIFIED.md
Step 4: Claude Code writes French and Spanish translations → Saves to vault: fr/pages/about.json, es/pages/about.json → Follows voice guide principles (no metaphors, positive framing, etc.)
Step 5: User reviews translations in vault (or says "looks good")
Step 6: Claude Code deploys both translations → POST /bw/v1/translate (French) → POST /bw/v1/translate (Spanish) → Updates vault files: status → "deployed", records wpml_post_id
Step 7: Claude Code verifies → GET /bw/v1/translation-status/12345 → Confirms FR and ES translations are linked → Optionally checks the live URLsExample: “Update the French Practice Sessions page — the English version changed”
Section titled “Example: “Update the French Practice Sessions page — the English version changed””Step 1: Claude Code reads the updated English page → GET /wp/v2/pages/67890
Step 2: Claude Code reads the existing French translation from vault → Reads fr/pages/practice-sessions.json
Step 3: Claude Code identifies what changed and updates the French translation
Step 4: Claude Code pushes the update → POST /bw/v1/translate (updates existing French post)
Step 5: Claude Code updates the vault file → translated_date updated, status → "deployed"7. Language Priority and Rollout Plan
Section titled “7. Language Priority and Rollout Plan”Phase 1: French (immediate priority)
Section titled “Phase 1: French (immediate priority)”French is the most pressing due to Quebec Bill 96 compliance and the Montreal audience.
Pages to translate first:
- All policy/legal pages (some already exist as standalone — convert to WPML pairs)
- Homepage
- Programs overview
- Practice Sessions landing page
- Study Group landing page
- About page
- Contact page
- WooCommerce checkout flow strings
- WooCommerce product pages
Phase 2: Spanish
Section titled “Phase 2: Spanish”Broader audience reach. Same page priority as French.
Phase 3+: Russian, Chinese, German
Section titled “Phase 3+: Russian, Chinese, German”One at a time. Each language follows the same workflow — Claude Code translates, stores in vault, pushes to WPML.
8. Quality Assurance
Section titled “8. Quality Assurance”Voice guide compliance
Section titled “Voice guide compliance”All translations must follow the Baseworks voice guides:
VOICE-GUIDE-UNIFIED.mdapplies to all languages- No generated metaphors
- Positive framing (describe what something IS)
- No exclamation marks in professional contexts
- “Fitness” is avoided — use appropriate equivalents in each language
Review process
Section titled “Review process”- Claude Code drafts — writes the translation following voice guides
- Vault review — Patrick or Asia reviews the JSON file, edits if needed, sets
status: approved - Deploy — Claude Code pushes approved translations to WordPress
- Live check — verify the page renders correctly in the target language
Ongoing maintenance
Section titled “Ongoing maintenance”When English content changes:
- Claude Code detects the change (or user flags it)
- Claude Code updates the corresponding translation(s) in the vault
- After review, deploys the update
- Vault files track the translation date so stale translations are visible
9. Setup Checklist
Section titled “9. Setup Checklist”One-time setup
Section titled “One-time setup”- Enable French and Spanish in WPML (WP Admin → WPML → Languages)
- Deploy the mu-plugin (
bw-wpml-translation-api.php) to the server - Create an application password for Claude Code
- Store API credentials on Patrick’s Mac and VPS
- Create the vault file structure (
03-resources/translations/) - Test the mu-plugin endpoints on staging first
- Convert existing standalone French policy pages to WPML translation pairs
- Export existing Japanese WPML translations to vault JSON files (for backup)
Per-language setup
Section titled “Per-language setup”- Enable the language in WPML
- Create the language directory in the vault (
03-resources/translations/{lang}/) - Translate and deploy pages in priority order (see Phase rollout above)
- Verify language switcher shows the new language
- Verify hreflang tags are correct
- Test WooCommerce checkout in the new language
10. Costs
Section titled “10. Costs”Additional cost: $0. The lifetime WPML deal covers everything. The mu-plugin is custom code deployed to the existing server. No new services or subscriptions needed.
If you later want a TMS web UI for reviewing translations (instead of reviewing JSON files in the vault), SimpleLocalize can be added at €35/mo — but it’s optional and not required for this workflow.
11. Limitations and Notes
Section titled “11. Limitations and Notes”-
Elementor complex layouts — WPML duplicates the Elementor structure per language. For pages with complex visual layouts, the first translation of each page may need some manual Elementor adjustment (column widths, text that’s longer in French, etc.). Subsequent text-only updates can go through the API.
-
WooCommerce emails — WPML + WCML handles email translations if WCML is part of the lifetime deal. If not, the mu-plugin approach from the edge plan (locale switching based on order meta) works as a lightweight alternative.
-
Dynamic JS content — Content rendered client-side by JavaScript (e.g., AJAX-loaded widgets) is translated by WPML if it goes through WordPress’s gettext system. Custom JS-rendered content may need manual string registration.
-
Menu translation — WPML’s menu sync feature duplicates menus per language. Initial setup is manual (WP Admin → Appearance → Menus), but text updates can go through string translation.
-
Slug translation — WPML translates URL slugs (e.g.,
/about/→/fr/a-propos/). The mu-plugin passes the translated slug; WPML handles the URL rewriting.
12. Relation to the Edge Plan
Section titled “12. Relation to the Edge Plan”This workflow uses WPML as the multilingual engine. The edge plan (03-resources/translation/wpml-migration-plan.md) describes a future architecture where WPML is removed entirely and replaced by a Cloudflare Worker.
The two plans are complementary:
- Now: Use this WPML workflow. Translate content. Build up the vault translation files.
- Future (if needed): The vault translation files become the source for the Cloudflare Worker. The migration path is: export WPML translations → they’re already in the vault as JSON → deploy to Cloudflare KV → switch over.
The vault file structure is intentionally the same in both plans, so the translations you build now are portable.
13. References
Section titled “13. References”- WPML Hooks Reference
- wpml_set_element_language_details
- Programmatically link WPML posts
- WPML String Translation
- WordPress Application Passwords
- WPML REST API usage
Vault Cross-References
Section titled “Vault Cross-References”- wpml-translation-automation-plan — interim workflow (manual paste into WPML editor) + translation rules that apply to all approaches
- wpml-migration-plan — future Cloudflare Worker architecture (vault translation files are portable)