Installation
- Download the plugin from the Download tab above
- Visit the plugins page in the WordPress admin area and upload the zipped plugin (latest version available from the Download tab)
- Visit Settings / Vizou Bling to set your language switcher display options (primary and secondary languages)
- Visit the Help tab at Settings / Vizou Bling to learn how to customize your theme’s templates (category, page, etc.)
- Be sure the Advanced Custom Fields plugin (any version) is installed and activated on your site!
Theming notes
- By default, secondary language Pages and Posts share the primary language page slug (the content is created and edited on the same page). To provide a direct, shareable link, secondary language URLs automatically have a suffix added to that slug, for example
example.com/about-the-artist/?lang=fr .
- Single-language URLs may be a potential issue with SEO for some types of sites, however, the primary language (usually the target audience) remains SEO-friendly and all language content is contained and exposed and indexable by search bots in a single Page or Post. This plugin might not be suitable for those who require highly-optimized SEO for both languages.
- Please note that this plugin is not built for compatibility with site block builders (Gutenberg, Elementor, etc.) because I can’t stand them. But it might work fine. Just saying.
- Coming shortly! Download some sample theme template files to better understand how to incorporate the following into your theme.
Plugin documentation
1. The two things the plugin actually requires
Everything else in this guide is a suggestion. Only two things are load-bearing:
- Secondary language content lives in ACF fields — For example,
french_page on Pages, french_post on Posts. Each is a group with title and content sub-fields (posts also get excerpt). Primary language content lives in WordPress’s own native title/content/excerpt fields — nothing plugin-specific there.
vizou_bling_current_language() tells you which language the visitor is currently in (i.e. 'en' or 'fr' ). Call it whenever a template needs to know.
The sample theme (in this case, Débutante) is configured with English as the primary (WP-native) language and French as the secondary (ACF-held) language. That assignment is set once, in language-config.php, when the plugin is set up for a project — it isn’t something a template needs to think about, and it isn’t meant to change afterward.
A note on the language names and codes used throughout this page: “English”, “French”, “en”, and “fr” are this project’s current configured values, pulled live from language-config.php — they aren’t fixed plugin vocabulary. A different project running this same plugin (forked for, say, English/Spanish) would see “Spanish” and “es” in their place everywhere on this page, including in the ACF field names and CSS classes referenced below. Nothing about the specific words matters to the plugin itself — only that whatever values are set in language-config.php are used consistently across the theme.
2. Minimal pattern: check for secondary language content, fall back to primary
This is the core pattern used throughout the site’s templates. It works with any layout — no specific markup required:
$secondary = get_field('french_page', $post_id);
$has_secondary = isset($secondary['title']) && !empty($secondary['title']);
if (!$has_secondary) {
// Only primary-language content exists — render it, regardless of current language.
echo $title; // primary-language title (native WP field)
echo $content;
} else {
// Both languages exist. Render whichever the visitor is currently viewing.
if (vizou_bling_current_language() === 'fr') {
echo $secondary['title'];
echo $secondary['content'];
} else {
echo $title; // primary-language title (native WP field)
echo $content;
}
}
This alone is enough to build a working bilingual template. Everything below is about making the switch happen without a page reload, which is optional but is what the rest of the site does.
3. A note on caching
If the template branches server-side on vizou_bling_current_language() the way the snippet above does, the rendered HTML will reflect whichever visitor’s cookie was active when a caching layer (Cloudflare or otherwise) last generated that page — every subsequent visitor gets that same version until the cache expires, regardless of their own language preference.
If that matters for a given template, render both languages into the page and use CSS/JS to show only the relevant one, rather than branching in PHP. That’s what the optional JS in section 4 is built to do.
4. Optional: instant client-side switching (no reload)
The plugin bundles a small set of scripts that toggle visibility between two rendered language blocks based on a body class, so a visitor can switch languages instantly without a fresh page request. This is entirely optional — it expects a markup convention, not something the plugin enforces:
<div id="tab-en"> English content here </div>
<div id="tab-fr"> French content here </div>
Both blocks render every time; the bundled JS shows/hides them based on the lang-en / lang-fr class it sets on <body>. If a template doesn’t use this convention, that’s fine — it just won’t get instant switching from the bundled JS, and would need its own toggle logic (or simply reload the page, which the minimal pattern in section 2 already supports via the ?lang= URL parameter).
For a working example, look at how page.php and category.php in the theme are built — they’re one implementation of this convention, not a required template. (This reference assumes the Vizou-built theme this plugin was originally paired with. Building a new theme from scratch instead? There’s nothing to look at yet — just follow the tab-en/tab-fr pattern described above directly.)
5. Visitor-facing text (“Read more”, the language switcher label)
Strings like the switcher label or a “read more” link don’t need to be hardcoded per-language in a template. Call:
vizou_bling_string('read_more', vizou_bling_current_language());
vizou_bling_string('switcher_label', vizou_bling_current_language());
This resolves in order: a site-wide value set on the Vizou Bling → Settings page, then a built-in default, then the vizou_bling_string filter — so if one specific template needs different wording than the site default (a landing page that wants “See details →” instead of “Read more”, for example), hook the filter locally in that template rather than changing the site-wide setting:
add_filter('vizou_bling_string', function ($value, $key, $lang) {
if ($key === 'read_more' && $lang === 'en') {
return 'See details →';
}
return $value;
}, 10, 3);
Both strings can also hold a narrow set of inline HTML instead of plain text — currently just <i> and <span> tags with a class attribute, enough for an icon-font glyph (Font Awesome or similar) in place of the text label. Anything outside that allow-list — links, buttons, inline styles, event-handler attributes — is stripped by wp_kses(), both when the Settings value is saved and every time vizou_bling_string() resolves a value afterward, regardless of whether it came from the default, the Settings option, or a filter.
Because of that, don’t wrap vizou_bling_string()‘s return value in esc_html() when echoing it — the value is already sanitized to a safe subset, and esc_html() would escape any icon markup back into visible text (<i class="fa-solid fa-globe">) instead of letting it render.
6. Optional: the language switcher control
The plugin doesn’t render a switcher for you — the toggle itself is left entirely to the theme. But if you want a switcher to actually work with the bundled JS, two things about it are load-bearing:
lang-switch class on the clickable element — required. langswitch.js binds its click handler to .lang-switch via a delegated event, so anything carrying that class anywhere on the page will trigger a language switch.
current-lang class on whatever element holds the visible label text — required. After a switch, the same script rewrites the text inside every .current-lang element to match the new language.
Everything else — the wrapping tag, whether it’s a <li>, a <span>, or something else, and where it sits on the page — is just one implementation choice, same as the tab-en/tab-fr convention in section 4. Wrap it in whatever suits the context: a <li> if it lives inside a <ul> nav, a <span> if it’s dropped inline elsewhere.
<a href="#" class="lang-switch">
<span class="current-lang"><?php echo vizou_bling_string('switcher_label', vizou_bling_current_language()); ?></span>
</a>
Note this deliberately does not wrap the call in esc_html() — see the callout at the end of section 5 for why.
One sync gotcha worth knowing: the label text is set from two different places, and they need to agree. On first page load, the PHP above calls vizou_bling_string() directly. After a click, langswitch.js instead rewrites .current-lang from VizouBlingConfig.strings.switcherLabel — a snapshot of those same values, taken once and localized in assets.php at page-load time, not called live. The bundled JS uses .html() rather than .text() for this update, so icon markup renders correctly on the client side too. If a per-template vizou_bling_string filter (section 5) changes the label in a way that snapshot doesn’t reflect, the switcher can show the “right” label on load and a stale one after switching, or the reverse. Keep any filter logic consistent regardless of when in the request it runs, or expect a mismatch.
For a working example, see content-nav.php in the theme. (As in section 4, that’s a reference to the Vizou-built theme, not a file this plugin ships or requires — a from-scratch theme just needs an element carrying both .lang-switch and .current-lang, wherever it makes sense to put one.)
7. Checklist for new theme templates
- Does the template show secondary language content at all? If not, nothing here applies.
- Does it check
vizou_bling_current_language() (or the ACF field directly, per section 2) rather than reading $_COOKIE directly?
- If it branches server-side on language, is that acceptable for this template even under page caching (section 3) — or should it render both languages and let JS toggle instead (section 4)?
- Are visitor-facing strings pulled from
vizou_bling_string() rather than hardcoded, so Settings and per-template filters both work?
- If this template includes a language switcher, does it carry both
.lang-switch and .current-lang (section 6)?