agentsclimarketplace

Wp bilingual

Skill yojahny55/claude-wp-builder/skills/wp-bilingual

Bilingual/multilingual i18n methodology using ACF _suffix pattern with transparent translation helpersFrom its SKILL.md

Install
npx -y skills add yojahny55/claude-wp-builder --skill wp-bilingual

Assembled 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 FieldFrench Field
hero_titlehero_title_eshero_title_fr
hero_subtitlehero_subtitle_eshero_subtitle_fr
cta_button_textcta_button_text_escta_button_text_fr
service_descriptionservice_description_esservice_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.php file must be included early in functions.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 of get_field()
  • prefix_get_sub_field() instead of get_sub_field()
  • prefix_get_repeater() instead of raw get_field() on repeaters
  • prefix__() / 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:

  1. Language constants
  2. prefix_get_current_lang()
  3. prefix_get_field()
  4. prefix_get_repeater()
  5. prefix_get_sub_field()
  6. prefix__() and prefix_e()
  7. prefix_get_lang_url()
  8. prefix_is_spanish() / prefix_is_lang()
  9. prefix_get_translations() (the static strings array)
  10. prefix_get_js_translations()

Summary Checklist

  • PREFIX_SUPPORTED_LANGS and PREFIX_DEFAULT_LANG constants 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 raw get_field())
  • prefix_get_repeater() used for repeater fields with translatable subfields specified
  • prefix_get_sub_field() used inside have_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 lang attribute set dynamically

Keep looking

Skills are one crate of 627,801. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.