Wp bilingual
Bilingual/multilingual i18n methodology using ACF _suffix pattern with transparent translation helpersFrom its SKILL.md
npx -y skills add yojahny55/claude-wp-builder --skill wp-bilingualAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 6 stars6 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
18.4 KB, as published. Nobody here has run it
Bilingual / Multilingual i18n System
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.
Core Concept: The _suffix Pattern
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 |
Rules
- Primary language fields have no suffix and are always required
- Secondary language fields have
_<lang>suffix and are optional (fall back to primary if empty) - This applies to text, textarea, WYSIWYG, and any content field
- Non-translatable fields (images, URLs, numbers, booleans) do NOT get duplicated
- ACF field instructions for secondary fields should say: "Leave empty to use English version"
Configuration Constants
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 Detection
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;
}
Important Notes on Cookies
setcookie()MUST be called before any HTML output (before headers are sent)- The
i18n.phpfile must be included early infunctions.php, before any template rendering - Cookie path is
/so it works across all pages - Cookie lifetime: 365 days
Cookie Persistence
When 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), '/');
Translation Helper Functions
prefix_get_field() -- Auto-Translating Field Getter
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); ?>
prefix_get_repeater() -- Repeater Field Translation
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;
prefix_get_sub_field() -- Sub-field Translation Inside Loops
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; ?>
prefix_t() and prefix_e() -- Static UI String Translation
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')); ?>">
prefix_is_lang() and prefix_get_current_lang()
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; ?>
Static Translations Array
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',
),
);
}
JavaScript Translations
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;
}
Language Switcher URL Generation
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>
Menu Locations: Per-Language Pattern
Register separate menu locations for each language. This allows admins to create fully localized menus in wp-admin.
Registration
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');
Usage in Templates
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).
ACF Field Creation Rules
When defining fields in inc/scf-fields.php for a bilingual site:
Field Organization
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,
),
Repeater Subfields
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
),
),
),
Critical Rule: Templates ALWAYS Use prefix_get_field()
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 ofget_field()prefix_get_sub_field()instead ofget_sub_field()prefix_get_repeater()instead of rawget_field()on repeatersprefix__()/prefix_e()instead of hardcoded strings
The only place get_field() is called directly is inside the helper functions themselves.
Setting the HTML lang Attribute
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(); ?>>
File Structure
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:
- Language constants
prefix_get_current_lang()prefix_get_field()prefix_get_repeater()prefix_get_sub_field()prefix__()andprefix_e()prefix_get_lang_url()prefix_is_spanish()/prefix_is_lang()prefix_get_translations()(the static strings array)prefix_get_js_translations()
Summary Checklist
-
PREFIX_SUPPORTED_LANGSandPREFIX_DEFAULT_LANGconstants defined - Language detection follows priority: URL param > cookie > browser > default
- Cookie set with 365-day expiry on language switch
-
prefix_get_field()used in ALL templates (never rawget_field()) -
prefix_get_repeater()used for repeater fields with translatable subfields specified -
prefix_get_sub_field()used insidehave_rows()loops -
prefix__()/prefix_e()used for all static UI strings - All secondary ACF fields have
_<lang>suffix and "Leave empty to use English version" instruction - Tab organization per language in ACF field groups
- Menu locations registered per language:
<location>-<lang> - Language switcher uses
remove_query_arg/add_query_arg - HTML
langattribute set dynamically