wp-bilingual · diff
git:20260920.b6eb6e5 to git:20261006.ab8c379
77 added, 416 removed. Audit A to A.
---
name: wp-bilingual
- description: Bilingual/multilingual i18n methodology using ACF _suffix pattern with transparent translation helpers
+ description: The suffix i18n model — one page carries every language, with ACF/SCF fields duplicated as _lang suffixes (hero_title_es) and resolved by prefix_get_field(), prefix_t() and prefix_e(), plus language detection, the language cookie, the switcher and menus. Use when the project's .claude/CLAUDE.md records the suffix i18n strategy, or records no strategy at all. Not for Polylang projects; those use wp-polylang.
user-invocable: false
---
# Bilingual / Multilingual i18n System
This skill defines ONE of the plugin's two translation methodologies: the **ACF/SCF _suffix pattern**. One set of pages, one set of fields, with suffixed duplicates for secondary languages.
> **Which one applies to a project is a decision, not a default.** `/wp-init`
> asks, and records the answer as **i18n strategy** in the project's
> `.claude/CLAUDE.md`. Read that line before assuming this skill applies.
>
> | Answer | Model | Skill |
> |---|---|---|
> | `suffix` | one page, fields duplicated as `_es` | this one |
> | `polylang` | one page **per language**, joined by translation groups | `wp-polylang` |
>
> The two never mix in one project. If the project says `polylang`, stop
> reading here and use the `wp-polylang` skill instead — the field naming,
> the menu registration and the helper behaviour all differ. An earlier
> version of this line claimed the plugin supported no Polylang at all, which
> stopped being true when `/wp-polylang` shipped.
+ ## Which helpers exist depends on the starter
+
+ The two starters ship different suffix contracts. Read `Template:` in the project's
+ `.claude/CLAUDE.md` and call only the helpers that template defines — a call to one it does
+ not define is a fatal error on the page.
+
+ | `Template:` | Helpers in `inc/i18n.php` | Source |
+ |---|---|---|
+ | `tailwind` (and legacy `basic`) | `prefix_get_current_lang()`, `prefix_get_field()`, `prefix_get_repeater()`, `prefix_get_sub_field()`, `prefix_t()`, `prefix_e()`, `prefix_is_lang()`, `prefix_get_translations()`, `prefix_get_lang_url()` | `${CLAUDE_PLUGIN_ROOT}/starter-theme/__tailwind__/inc/i18n.php` |
+ | `cinematic` | `prefix_current_lang()`, `prefix_b( $en, $es )`, `prefix_setting( $name )` — plus `prefix_get_sub( $name )` in `inc/scenes-renderer.php` for scene sub-fields | `${CLAUDE_PLUGIN_ROOT}/starter-theme/__cinematic__/inc/i18n.php` |
+
+ Everything from "Translation Helper Functions" down describes the `tailwind` contract. On
+ `cinematic`, a literal is `prefix_b( 'English', 'Español' )` (it allows `br`, `em`, `strong`,
+ `i`, `b` and `span` through `wp_kses`), an options-page value is `prefix_setting( 'site_logo' )`
+ (tries `site_logo_es` on a Spanish request), and there is no `prefix_get_field()`. The
+ cinematic layer knows `en` and `es` only, and reads the `?lang=` parameter and the cookie but
+ never sets the cookie.
+
+ ## Reference files
+
+ - [references/i18n-helpers.md](references/i18n-helpers.md) — where the implementation lives
+ (the starter's `inc/i18n.php`, never a copy), why detection runs in its order, the cookie and
+ its `init` hook, the fallback rules, adding a string or passing strings to JavaScript, and a
+ switcher template. Read when changing `inc/i18n.php` or building the switcher; templates only
+ need the calls shown below.
+ - [references/acf-fields.md](references/acf-fields.md) — complete field definitions for a
+ suffix site: language tabs and suffixed repeater subfields. Read when writing
+ `fields/*.php` for a project on this model.
+
---
## 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 name the PRIMARY language, and are written in it:
*"Leave empty to use the English version"* on an English-primary project, *"Dejar vacío para
usar la versión en español."* on a Spanish-primary one. Read which language is primary from
`.claude/CLAUDE.md`; the tables above use an English-primary project as their example, not as
the rule. Every other string the editor reads — group titles, tab and field labels,
`button_label`, `message` — follows the same rule. Field `name`s and `key`s do not: they are
meta keys, and translating one orphans the rows already stored under the old name. See
"Editor Language" in `agents/wp-acf.md`.
---
## Configuration Constants
Define supported languages and the default at the top of `inc/i18n.php`.
```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**
- ```php
- 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
+ ### The language cookie
- 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.
+ A switcher link carries `?lang=es`. The **first** call to `prefix_get_current_lang()` on that
+ request sees the parameter and sets the `prefix_lang` cookie (path `/`, 365 days), so later
+ requests need no parameter. Its result is cached for the rest of the request, so no later call
+ sets the cookie.
- ```php
- // Cookie is set when URL param is detected
- setcookie('prefix_lang', $current_lang, time() + (365 * 24 * 60 * 60), '/');
- ```
+ That first call must happen **before any output**, or `setcookie()` fails — the header can no
+ longer be sent once `<!DOCTYPE html>` has gone out, and the choice silently lasts one page.
+ Including `i18n.php` early does not achieve this; calling the function does. The starter hooks
+ `prefix_get_current_lang()` on `init` for exactly this reason. Keep that hook, and never let a
+ template be the first caller.
---
## Translation Helper Functions
+ Each helper's implementation is the starter's `inc/i18n.php` (the table above); below is how
+ templates call them.
+
### 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.
- ```php
- 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:**
```php
<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.
- ```php
- 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:**
```php
// 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.
- ```php
- 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
<?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.
- ```php
- /**
- * 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:**
```php
<!-- 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')); ?>">
+ <a href="#" aria-label="<?php echo esc_attr(prefix_t('nav_schedule')); ?>">
```
- ### prefix_is_lang() and prefix_get_current_lang()
-
- Convenience helpers for language checks.
+ `prefix_t()` returns the key itself when no language has an entry for it, never `''` — so
+ `prefix_t( $key ) ?: $default` never reaches `$default`. Compare against the key instead.
- ```php
- /**
- * Check if current language matches
- */
- function prefix_is_lang($lang) {
- return prefix_get_current_lang() === $lang;
- }
+ ### prefix_is_lang() and prefix_get_current_lang()
- /**
- * Alias: check if current language is Spanish
- */
- function prefix_is_spanish() {
- return prefix_get_current_lang() === 'es';
- }
- ```
+ Convenience helpers for language checks. There is no per-language alias; pass the code.
**Usage:**
```php
- <?php if (prefix_is_spanish()) : ?>
- <html lang="es">
- <?php else : ?>
- <html lang="en">
+ <?php if (prefix_is_lang('es')) : ?>
+ <p class="notice"><?php prefix_e('notice_spanish_only'); ?></p>
<?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.
-
- ```php
- 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()`.
-
- ```php
- 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;
- }
- ```
+ Every hardcoded UI string is a key in `prefix_get_translations()`, mapping each language code
+ to its text. Adding one, and passing strings to JavaScript (there is no separate JavaScript
+ helper), are in `references/i18n-helpers.md`.
---
## Language Switcher URL Generation
- Use `remove_query_arg()` and `add_query_arg()` to build language toggle URLs.
-
- ```php
- function prefix_get_lang_url($lang) {
- $url = remove_query_arg('lang');
- return add_query_arg('lang', $lang, $url);
- }
- ```
-
- **Language switcher in a template:**
-
- ```php
- <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>
- ```
+ `prefix_get_lang_url( $lang )` returns the current URL with `?lang=` replaced, built with
+ `remove_query_arg()` and `add_query_arg()`. A switcher template is in
+ `references/i18n-helpers.md`.
---
## Menu Locations: Per-Language Pattern
Register separate menu locations for each language. This allows admins to create fully localized menus in wp-admin.
### Registration
```php
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
<?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 `fields/*.php` for a bilingual site (see wp-theme-standards for the field loader / Local JSON model — `fields/*.php` is a one-time bootstrap seed, `acf-json/*.json` is the dashboard-editable source of truth):
- ### Field Organization
-
Use **Tab fields** to organize languages in the admin UI.
-
- ```php
- // 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.
- ```php
- 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
- ),
- ),
- ),
- ```
+ Both, as full field definitions: `references/acf-fields.md`.
---
## Critical Rule: Templates ALWAYS Use prefix_get_field()
Templates must **NEVER** call `get_field()` directly. Always use the translation-aware wrapper.
```php
// 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
+ - `prefix_t()` / `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.
+ `header.php` prints `<html <?php language_attributes(); ?>>` and nothing else on that tag.
+ `language_attributes()` reads the site locale, which on a suffix site is the primary language
+ on every request, `?lang=es` included. Both starters' `inc/i18n.php` therefore filter it, so
+ the attribute follows the request (WCAG 3.1.1). The `tailwind` form (`cinematic` calls
+ `prefix_current_lang()`):
```php
- <!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(); ?>>
+ add_filter('language_attributes', function ($output) {
+ return preg_replace('/lang="[^"]*"/', 'lang="' . esc_attr(prefix_get_current_lang()) . '"', $output);
+ });
```
+ Never add a second `lang="…"` after `language_attributes()` on the `<html>` tag. A browser keeps
+ the first of two duplicate attributes, which is the site locale, so the second one changes
+ nothing.
+
---
## File Structure
- The i18n system lives in a single file included early in `functions.php`, before the field loader's `acf/init` hook runs.
-
- ```php
- // functions.php — i18n must load before the fields/*.php bootstrap
- require get_template_directory() . '/inc/i18n.php';
-
- // Field groups loaded via the acf/init bootstrap loader (fields/*.php seeds
- // acf-json/, which becomes the dashboard-editable source of truth) —
- // see wp-theme-standards SKILL.md for the full loader.
- ```
-
- 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()`
+ The i18n system is one file, `inc/i18n.php`, required from `functions.php` before the field
+ loader's `acf/init` hook runs (field definitions may call its helpers). What it defines is the
+ table at the top of this skill.
---
## 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
+ - [ ] Cookie set with 365-day expiry on language switch, by a first call made on `init`
- [ ] `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
+ - [ ] `prefix_t()` / `prefix_e()` used for all static UI strings
+ - [ ] All secondary ACF fields have the `_<lang>` suffix and an instruction written in the primary language (see Rules)
- [ ] 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
+ - [ ] HTML `lang` attribute follows the request through the `language_attributes` filter