From claude-wp-builder
Bilingual/multilingual i18n methodology using ACF _suffix pattern with transparent translation helpers
How this skill is triggered — by the user, by Claude, or both
Slash command
/claude-wp-builder:wp-bilingualThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill defines the translation methodology for WordPress themes that need to support multiple languages. It uses the **ACF/SCF _suffix pattern** -- no WPML, no Polylang, no separate pages per language. One set of pages, one set of fields, with suffixed duplicates for secondary languages.
This skill defines the translation methodology for WordPress themes that need to support multiple languages. It uses the ACF/SCF _suffix pattern -- no WPML, no Polylang, no separate pages per language. One set of pages, one set of fields, with suffixed duplicates for secondary languages.
For every translatable ACF/SCF field, the primary language (typically English) uses the base field name. Each secondary language gets a duplicate field with a language suffix appended.
| Primary Field (EN) | Spanish Field | French Field |
|---|---|---|
hero_title | hero_title_es | hero_title_fr |
hero_subtitle | hero_subtitle_es | hero_subtitle_fr |
cta_button_text | cta_button_text_es | cta_button_text_fr |
service_description | service_description_es | service_description_fr |
_<lang> suffix and are optional (fall back to primary if empty)Define supported languages and the default at the top of inc/i18n.php.
// Define supported languages
define('PREFIX_SUPPORTED_LANGS', array('en', 'es'));
define('PREFIX_DEFAULT_LANG', 'en');
Language is detected using a strict priority chain. The first match wins.
Priority: URL parameter > Cookie > Browser Accept-Language > Default
function prefix_get_current_lang() {
static $current_lang = null;
if ($current_lang !== null) {
return $current_lang;
}
// 1. Check URL parameter
if (isset($_GET['lang']) && in_array($_GET['lang'], PREFIX_SUPPORTED_LANGS)) {
$current_lang = sanitize_text_field($_GET['lang']);
// Set cookie for persistence (365 days)
setcookie('prefix_lang', $current_lang, time() + (365 * 24 * 60 * 60), '/');
return $current_lang;
}
// 2. Check cookie
if (isset($_COOKIE['prefix_lang']) && in_array($_COOKIE['prefix_lang'], PREFIX_SUPPORTED_LANGS)) {
$current_lang = sanitize_text_field($_COOKIE['prefix_lang']);
return $current_lang;
}
// 3. Check browser language (Accept-Language header)
if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
$browser_lang = substr($_SERVER['HTTP_ACCEPT_LANGUAGE'], 0, 2);
if (in_array($browser_lang, PREFIX_SUPPORTED_LANGS)) {
$current_lang = $browser_lang;
return $current_lang;
}
}
// 4. Default language
$current_lang = PREFIX_DEFAULT_LANG;
return $current_lang;
}
setcookie() MUST be called before any HTML output (before headers are sent)i18n.php file must be included early in functions.php, before any template rendering/ so it works across all pagesWhen the user clicks a language switcher link (e.g., ?lang=es), the cookie is set in the prefix_get_current_lang() function. Subsequent page loads read the cookie, so the URL parameter is only needed once.
// Cookie is set when URL param is detected
setcookie('prefix_lang', $current_lang, time() + (365 * 24 * 60 * 60), '/');
This is the primary function for retrieving any ACF/SCF field. It checks the current language, tries the suffixed field first, and falls back to the primary field.
function prefix_get_field($field_name, $post_id = null) {
$lang = prefix_get_current_lang();
// If secondary language, try suffixed field first
if ($lang !== PREFIX_DEFAULT_LANG) {
$translated_field = $field_name . '_' . $lang;
$value = get_field($translated_field, $post_id);
// If translated field has a value, return it
if (!empty($value)) {
return $value;
}
}
// Fallback to primary (default) field
return get_field($field_name, $post_id);
}
Usage in templates:
<h1><?php echo esc_html(prefix_get_field('hero_title')); ?></h1>
<p><?php echo wp_kses_post(prefix_get_field('hero_description')); ?></p>
<!-- With post ID -->
<?php $logo = prefix_get_field('site_logo', 'option'); ?>
<!-- With specific post -->
<?php $title = prefix_get_field('custom_title', $post->ID); ?>
Translates specific subfields within a repeater while leaving non-translatable subfields (images, URLs) untouched.
function prefix_get_repeater($field_name, $translatable_subfields = array(), $post_id = null) {
$lang = prefix_get_current_lang();
$repeater = get_field($field_name, $post_id);
if (!$repeater || !is_array($repeater)) {
return array();
}
// If default language or no translatable subfields, return as-is
if ($lang === PREFIX_DEFAULT_LANG || empty($translatable_subfields)) {
return $repeater;
}
// Process each row for translations
foreach ($repeater as $index => $row) {
foreach ($translatable_subfields as $subfield) {
$translated_key = $subfield . '_' . $lang;
// If translated subfield exists and has value, override the primary
if (isset($row[$translated_key]) && !empty($row[$translated_key])) {
$repeater[$index][$subfield] = $row[$translated_key];
}
}
}
return $repeater;
}
Usage:
// Services repeater: translate 'title' and 'description', keep 'icon' and 'link' as-is
$services = prefix_get_repeater('services', array('title', 'description'));
foreach ($services as $service) : ?>
<div class="service-card">
<img src="<?php echo esc_url($service['icon']['url']); ?>" alt="">
<h3><?php echo esc_html($service['title']); ?></h3>
<p><?php echo esc_html($service['description']); ?></p>
</div>
<?php endforeach;
Used inside have_rows() loops (repeaters, flexible content) to get translated subfield values.
function prefix_get_sub_field($field_name) {
$lang = prefix_get_current_lang();
if ($lang !== PREFIX_DEFAULT_LANG) {
$value = get_sub_field($field_name . '_' . $lang);
if (!empty($value)) {
return $value;
}
}
return get_sub_field($field_name);
}
Usage inside have_rows():
<?php if (have_rows('team_members')) : ?>
<?php while (have_rows('team_members')) : the_row(); ?>
<div class="team-member">
<h3><?php echo esc_html(prefix_get_sub_field('name')); ?></h3>
<p><?php echo esc_html(prefix_get_sub_field('bio')); ?></p>
</div>
<?php endwhile; ?>
<?php endif; ?>
For hardcoded UI strings (navigation labels, button text, form labels) that do not come from ACF fields.
/**
* Get static translation string (return)
*/
function prefix__($key) {
$lang = prefix_get_current_lang();
$translations = prefix_get_translations();
if (isset($translations[$key][$lang])) {
return $translations[$key][$lang];
}
// Fallback to default language
if (isset($translations[$key][PREFIX_DEFAULT_LANG])) {
return $translations[$key][PREFIX_DEFAULT_LANG];
}
// Return key if translation not found
return $key;
}
/**
* Echo static translation string (with escaping)
*/
function prefix_e($key) {
echo esc_html(prefix__($key));
}
Usage:
<!-- In templates -->
<a href="#services"><?php prefix_e('nav_services'); ?></a>
<button><?php prefix_e('btn_learn_more'); ?></button>
<!-- When you need the raw string (e.g., for attributes) -->
<a href="#" aria-label="<?php echo esc_attr(prefix__('nav_schedule')); ?>">
Convenience helpers for language checks.
/**
* Check if current language matches
*/
function prefix_is_lang($lang) {
return prefix_get_current_lang() === $lang;
}
/**
* Alias: check if current language is Spanish
*/
function prefix_is_spanish() {
return prefix_get_current_lang() === 'es';
}
Usage:
<?php if (prefix_is_spanish()) : ?>
<html lang="es">
<?php else : ?>
<html lang="en">
<?php endif; ?>
Define all hardcoded UI strings in a central translations function. Each entry is an associative array keyed by language code.
function prefix_get_translations() {
return array(
// Navigation
'nav_home' => array(
'en' => 'Home',
'es' => 'Inicio',
),
'nav_services' => array(
'en' => 'Services',
'es' => 'Servicios',
),
'nav_pricing' => array(
'en' => 'Pricing',
'es' => 'Precios',
),
'nav_contact' => array(
'en' => 'Contact',
'es' => 'Contacto',
),
// Buttons
'btn_learn_more' => array(
'en' => 'Learn More',
'es' => 'Saber Mas',
),
'btn_get_started' => array(
'en' => 'Get Started',
'es' => 'Comenzar',
),
'btn_schedule' => array(
'en' => 'Schedule Appointment',
'es' => 'Agendar Cita',
),
// Footer
'footer_services' => array(
'en' => 'Services',
'es' => 'Servicios',
),
'footer_quick_links' => array(
'en' => 'Quick Links',
'es' => 'Enlaces Rapidos',
),
'footer_privacy' => array(
'en' => 'Privacy Policy',
'es' => 'Politica de Privacidad',
),
'footer_terms' => array(
'en' => 'Terms & Conditions',
'es' => 'Terminos y Condiciones',
),
// Social
'social_follow_us' => array(
'en' => 'Follow Us',
'es' => 'Siguenos',
),
);
}
For strings needed in client-side JS, create a filtered subset and pass via wp_localize_script().
function prefix_get_js_translations() {
$all = prefix_get_translations();
$lang = prefix_get_current_lang();
$js_strings = array();
// Pick only the keys needed in JS
$js_keys = array('btn_learn_more', 'btn_schedule', 'calc_per_month');
foreach ($js_keys as $key) {
if (isset($all[$key][$lang])) {
$js_strings[$key] = $all[$key][$lang];
}
}
return $js_strings;
}
Use remove_query_arg() and add_query_arg() to build language toggle URLs.
function prefix_get_lang_url($lang) {
$url = remove_query_arg('lang');
return add_query_arg('lang', $lang, $url);
}
Language switcher in a template:
<div class="lang-switcher">
<?php $current_lang = prefix_get_current_lang(); ?>
<?php foreach (PREFIX_SUPPORTED_LANGS as $lang) : ?>
<?php if ($lang !== $current_lang) : ?>
<a href="<?php echo esc_url(prefix_get_lang_url($lang)); ?>"
class="lang-switcher__link"
aria-label="<?php echo esc_attr('Switch to ' . strtoupper($lang)); ?>">
<?php echo esc_html(strtoupper($lang)); ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
</div>
Register separate menu locations for each language. This allows admins to create fully localized menus in wp-admin.
function prefix_setup() {
register_nav_menus(array(
'primary-en' => __('Primary Navigation (EN)', 'theme-slug'),
'primary-es' => __('Primary Navigation (ES)', 'theme-slug'),
'mobile-en' => __('Mobile Navigation (EN)', 'theme-slug'),
'mobile-es' => __('Mobile Navigation (ES)', 'theme-slug'),
'footer-en' => __('Footer Navigation (EN)', 'theme-slug'),
'footer-es' => __('Footer Navigation (ES)', 'theme-slug'),
));
}
add_action('after_setup_theme', 'prefix_setup');
Select the menu location dynamically based on the current language.
<?php
$lang = prefix_get_current_lang();
wp_nav_menu(array(
'theme_location' => 'primary-' . $lang,
'container' => false,
'fallback_cb' => 'prefix_nav_fallback',
'items_wrap' => '%3$s',
'walker' => new Prefix_Nav_Walker(),
));
?>
The pattern is: <location>-<lang> (e.g., primary-en, primary-es, mobile-en, mobile-es).
When defining fields in inc/scf-fields.php for a bilingual site:
Use Tab fields to organize languages in the admin UI.
// English Tab
array(
'key' => 'field_hero_tab_en',
'label' => 'English',
'type' => 'tab',
'placement' => 'top',
),
array(
'key' => 'field_hero_title',
'label' => 'Hero Title',
'name' => 'hero_title',
'type' => 'text',
'required' => 1,
),
array(
'key' => 'field_hero_description',
'label' => 'Hero Description',
'name' => 'hero_description',
'type' => 'textarea',
'required' => 1,
),
// Spanish Tab
array(
'key' => 'field_hero_tab_es',
'label' => 'Espanol',
'type' => 'tab',
'placement' => 'top',
),
array(
'key' => 'field_hero_title_es',
'label' => 'Hero Title (ES)',
'name' => 'hero_title_es',
'type' => 'text',
'instructions' => 'Leave empty to use English version.',
'required' => 0,
),
array(
'key' => 'field_hero_description_es',
'label' => 'Hero Description (ES)',
'name' => 'hero_description_es',
'type' => 'textarea',
'instructions' => 'Leave empty to use English version.',
'required' => 0,
),
Inside repeaters, add suffixed subfields for each translatable text subfield.
array(
'key' => 'field_services',
'label' => 'Services',
'name' => 'services',
'type' => 'repeater',
'sub_fields' => array(
array(
'key' => 'field_service_icon',
'label' => 'Icon',
'name' => 'icon',
'type' => 'image',
),
array(
'key' => 'field_service_title',
'label' => 'Title (EN)',
'name' => 'title',
'type' => 'text',
),
array(
'key' => 'field_service_title_es',
'label' => 'Title (ES)',
'name' => 'title_es',
'type' => 'text',
'instructions' => 'Leave empty to use English version.',
),
array(
'key' => 'field_service_description',
'label' => 'Description (EN)',
'name' => 'description',
'type' => 'textarea',
),
array(
'key' => 'field_service_description_es',
'label' => 'Description (ES)',
'name' => 'description_es',
'type' => 'textarea',
'instructions' => 'Leave empty to use English version.',
),
array(
'key' => 'field_service_link',
'label' => 'Link',
'name' => 'link',
'type' => 'url',
// No _es version — URLs are typically language-neutral
),
),
),
Templates must NEVER call get_field() directly. Always use the translation-aware wrapper.
// WRONG — bypasses translation system
$title = get_field('hero_title');
// CORRECT — auto-translates based on current language
$title = prefix_get_field('hero_title');
This rule applies everywhere:
prefix_get_field() instead of get_field()prefix_get_sub_field() instead of get_sub_field()prefix_get_repeater() instead of raw get_field() on repeatersprefix__() / prefix_e() instead of hardcoded stringsThe only place get_field() is called directly is inside the helper functions themselves.
In header.php, set the document language dynamically.
<!DOCTYPE html>
<html <?php language_attributes(); ?> lang="<?php echo esc_attr(prefix_get_current_lang()); ?>">
<head>
<meta charset="<?php bloginfo('charset'); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<?php wp_head(); ?>
</head>
<body <?php body_class(); ?>>
The i18n system lives in a single file included early in functions.php.
// functions.php — i18n must load before SCF fields
require get_template_directory() . '/inc/i18n.php';
require get_template_directory() . '/inc/scf-fields.php';
The inc/i18n.php file contains:
prefix_get_current_lang()prefix_get_field()prefix_get_repeater()prefix_get_sub_field()prefix__() and prefix_e()prefix_get_lang_url()prefix_is_spanish() / prefix_is_lang()prefix_get_translations() (the static strings array)prefix_get_js_translations()PREFIX_SUPPORTED_LANGS and PREFIX_DEFAULT_LANG constants definedprefix_get_field() used in ALL templates (never raw get_field())prefix_get_repeater() used for repeater fields with translatable subfields specifiedprefix_get_sub_field() used inside have_rows() loopsprefix__() / prefix_e() used for all static UI strings_<lang> suffix and "Leave empty to use English version" instruction<location>-<lang>remove_query_arg / add_query_arglang attribute set dynamicallyCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub yojahny55/claude-wp-builder --plugin claude-wp-builder