The part I expected to take longest was finished in a single sitting. One assets/js/site-i18n.js that reads the active language from localStorage under the key site-lang, one .js-lang button wired into both the desktop nav and the mobile menu, and a dictionary. Click the button, switch the language, rewrite the text. Done.
The constraint on the project fit in one sentence: the site had to be bilingual, French and English, with no translation plugin. Not WPML, not Polylang. The rest was up to me, and what I had was a custom WordPress theme plus JavaScript on the client side.
What actually took time was the question that came after the button worked: which text.
The text on one page does not come from one place
Written down as a task, "translate the page" sounds like a single job. Opened up, it is three things that behave differently.
There is UI chrome, the static strings I type into the templates myself: nav labels, footer column headings, button text. There is CMS content, the titles and excerpts and card bullets typed in the admin, which can change any afternoon without me touching the code. And there are full article bodies on the detail pages, complete HTML running for paragraphs.
All three need a French version, and no single approach is comfortable for all three. What it became was three mechanisms inside one engine file, separated by which attribute an element carries.
<!-- 1. static string, element points at a dictionary key -->
<a data-tr="nav.services" href="/services">Services</a>
<!-- 2. CMS content, element carries its own French text -->
<h3 data-tr-fr="Notre equipe">Our team</h3>
<!-- 3. article body, the container's contents get swapped wholesale -->
<div data-tr-html="article-body">...</div>Mechanism one: a static dictionary through data-tr
The straightforward one. The element carries a key, the key points at a dictionary entry, and the engine writes the result through innerHTML. I use innerHTML rather than textContent on purpose, because some of the French strings contain inline tags such as <em>, and those have to render as markup instead of being escaped into visible text.
The French is not my translation. The older version of the site was already in French, and I kept an HTML snapshot of it in a snapshot/ folder. Every entry in this dictionary is pulled from that original copy, so the wording is exactly what the client already uses.
const dict = {
fr: {
'nav.services': 'Nos services',
'foot.tagline': 'Notre <em>savoir-faire</em>, chaque jour',
},
};
function applyStatic(lang) {
document.querySelectorAll('[data-tr]').forEach((el) => {
const val = dict[lang]?.[el.dataset.tr];
if (val != null) el.innerHTML = val;
});
}Mechanism two: elements that carry their own French
A static dictionary stops working the moment the text comes from the CMS. Card titles, excerpts, bullets, durations, and team roles are all typed in the admin. I cannot pin a fixed dictionary key to something somebody else can rewrite later today.
So I turned the direction around. Instead of the element pointing at a dictionary, the element carries its own French version in an attribute, and the engine simply swaps its contents for that attribute value. Unlike the first mechanism, this one is applied through textContent, which does not parse the value as markup. The attribute value can come from FR post meta, or from an EN to FR map written in PHP.
I went with the PHP map. It lives in inc/fr-content.php, keyed by slug, and split into four functions: one for service entries, one for the team, one for pricing, and one dedicated to attribute values. The source of the French is the same as before, the snapshot of the old site, this time its index page and the service pages under it.
// inc/fr-content.php
function thm_svc_fr( $slug, $field ) {
$map = array(
'consultation' => array(
'title' => 'Consultation',
'excerpt' => 'Un premier rendez-vous pour faire le point.',
),
);
return $map[ $slug ][ $field ] ?? '';
}
// in the card template
<h3 data-tr-fr="<?php echo esc_attr( thm_svc_fr( $slug, 'title' ) ); ?>">
<?php the_title(); ?>
</h3>Later the client asked for the services archive to be restructured, with categories that were actually relevant and an order that felt premium rather than generic. That restructure introduced a new category rail, and the rail labels needed a French map of their own. That one sits as a $rail_fr map inside the archive template, plus one new dictionary key for the rail heading.
Mechanism three: swapping an entire article body
Those two mechanisms covered nearly every page. What they could not cover was the service detail page, which carries a full article body. An attribute is obviously not the place for several paragraphs of HTML, and breaking it into dozens of dictionary keys makes no sense either.
The third mechanism is data-tr-html. The container gets marked, the French version sits in a hidden <template> element on the same page, and the engine swaps the container's innerHTML for the template's contents. Because a <template>'s contents are not rendered and execute nothing until they are used, the FR version can ride along on the same page with no side effects.
<div class="i18n-swap" data-tr-html="article-body">
<p>The English body, rendered by the theme.</p>
<p>Second paragraph.</p>
</div>
<template id="fr-article-body">
<p>Le texte de l'article en francais.</p>
<p>Deuxieme paragraphe.</p>
</template>I wrap that swap container in a display: contents wrapper so the section layout does not shift when the contents change. The wrapper stays in the DOM as the swap target, but it generates no layout box of its own, so its children are still read by the parent grid or flex as though the wrapper were not there.
.i18n-swap {
display: contents;
}The French article bodies are not pasted into each post by hand. A tools/seed-fr-blocks.php script writes them into the post meta _svc_blocks_fr and _svc_sig_fr, sourced once again from the snapshot of the old site's service pages. One page has its entire contents swapped by the same mechanism rather than just its article body, and for that page the FR template is written inline.
There is one small decision here that saved a lot of time: the FAQ uses native <details>. An innerHTML swap removes the old nodes and creates new ones, so a JS-driven accordion would lose its event listeners on every language switch and need rebinding. Native <details> needs no rebinding at all, because the browser handles the toggle.
Getting back to English without a reload
An innerHTML swap runs one way. Once a container's contents are overwritten with the French version, the English version is no longer in the DOM, and pressing the language button again will not bring it back on its own. So the engine caches the English innerHTML in a WeakMap keyed by the element, and restores it when the language flips back to English.
const enCache = new WeakMap();
function toFrench(el, frHtml) {
if (!enCache.has(el)) enCache.set(el, el.innerHTML);
el.innerHTML = frHtml;
}
function toEnglish(el) {
if (enCache.has(el)) el.innerHTML = enCache.get(el);
}The consequence is worth stating plainly. This engine rewrites innerHTML across many elements at once, so anything else that rewrites the DOM on those same elements will fight it unless it runs after i18n has finished applying. In this theme the motion script is registered with a dependency on the i18n script, so that ordering is enforced by the WordPress enqueue system. I learned why that matters the expensive way, and that story is written up separately.
What is deliberately left untranslated
Proper nouns, place names, addresses, email addresses, and language names stay exactly as they are in both modes. They keep showing up in the scan results as strings that do not differ between EN and FR, and that is the correct outcome.
From "still a lot missing" to 141, then to zero
For a while I thought coverage was complete. The dev flagged that a lot was still missing, and "a lot" is not something anybody can work through. So I ran an MCP scan with the site in FR mode, and it came back with 141 untranslated strings.
That list is what changed the situation, because it was concrete: the footer, from the tagline down to every column heading and link plus the two legal pages, the marquee terms, the scroll hint in the pillar section, the opening-hours line, the mobile story poster, the CMS-driven service cards, and the team roles. The first item I worked through was as small as adding data-tr="foot.tagline" to the footer template.
Once everything was worked through, the same scan on the homepage came back with zero real misses. Of the 141 lines on that list, the number still untranslated is zero. The hits the scan still reports are the proper nouns and addresses that were deliberately skipped.
Coverage is not uniform across pages, and that is on purpose. The homepage, the services archive, and the detail pages get full FR, article bodies included. The 404 page, the search results page, and the legal pages get only their chrome, with the legal page title coming from a slug-based map. The legal body prose is still a placeholder, while the why-us page is fully translated.
Lessons
- "Make the site bilingual" is not one job. UI chrome, CMS content, and long article bodies need three different mechanisms, and forcing one mechanism onto all three only moves the complexity somewhere else.
- If a translated string is allowed to contain inline markup, apply it through
innerHTML. If the value is CMS data,textContentis the right call. - For text that comes from the CMS, let the element carry its own translated version in an attribute, and supply the map from PHP keyed by slug.
- A container whose contents get swapped is best wrapped in a
display: contentswrapper, so the swap does not disturb the surrounding section layout. - Cache the source-language HTML before overwriting it, for instance in a
WeakMap. Without that, the version is gone after a single language switch. - Components that must survive a swap should be native.
<details>needs no rebinding, a JS accordion does. - "Still a lot missing" cannot be worked through, a number can. The scan that returned a 141-line list is what turned a feeling into a work queue.