Posez votre question et obtenez un résumé du document en referencant cette page et le Provider AI de votre choix
Historique des versions
- "Initial history"v9.4.129/08/2026
Le contenu de cette page a été traduit à l'aide d'une IA.
Voir la dernière version du contenu original en anglaisSi vous avez une idée d’amélioration pour améliorer cette documentation, n’hésitez pas à contribuer en submitant une pull request sur GitHub.
Lien GitHub de la documentationCopier le Markdown du doc dans le presse-papiers
Traduisez votre application htmx en utilisant Intlayer | Internationalization (i18n)
htmx ne rend aucun contenu de sa propre initiative. Chaque libellé qu'un visiteur lit est du HTML produit par votre serveur, et chaque swap est une requête HTTP distincte. L'internationalisation d'une app htmx est donc une préoccupation serveur : la locale doit être résolue à chaque requête, et chaque fragment doit être rendu dans cette locale.
Intlayer couvre cela à travers ses intégrations backend, qui détectent la locale par requête et exposent votre contenu déclaré au handler qui construit le HTML.
Table des matières
Les trois règles de l'i18n dans une app htmx
Une seule page peut déclencher des dizaines d'échanges. Chacun est une demande nouvelle sans mémoire de la page qui l'a émise. Si la locale réside dans une variable définie lors du rendu initial, chaque fragment après celui-ci revient à la langue par défaut.
Le middleware Intlayer résout la locale à partir de la demande elle-même, de sorte qu'un fragment servi à la minute dix répond dans la même langue que la page servie à la minute zéro.
Deux porteurs fonctionnent avec htmx. Un cookie (INTLAYER_LOCALE) est envoyé automatiquement par le navigateur à chaque demande, y compris les demandes htmx. Un en-tête (x-intlayer-locale) peut être attaché aux demandes htmx avec l'attribut hx-headers. Les deux sont lus par défaut.
Une valeur traduite interpolée dans un fragment est du markup. Échappez-la, exactement comme vous le feriez pour toute autre valeur dynamique, afin qu'une traduction contenant < ne puisse pas casser le document dans lequel elle est échangée.
Guide Étape par Étape
Voir Modèle d'Application sur GitHub.
Installer les Dépendances
Installez
intlayerplus l'intégration pour votre serveur.bashCopier le codeCopier le code dans le presse-papiers
bashCopier le codeCopier le code dans le presse-papiers
bashCopier le codeCopier le code dans le presse-papiers
bashCopier le codeCopier le code dans le presse-papiers
Express et Fastify lisent le cookie de locale via leurs propres parseurs de cookies, donc ceux-ci doivent être installés parallèlement. Hono et Elysia analysent les cookies nativement.
htmx lui-même est une seule balise de script, ajoutée à l'étape 4.
Configuration de votre projet
Créez un
intlayer.config.tsà la racine de votre projet :intlayer.config.tsCopier le codeCopier le code dans le presse-papiers
import { Locales, type IntlayerConfig } from "intlayer"; const config: IntlayerConfig = { internationalization: { locales: [Locales.ENGLISH, Locales.FRENCH, Locales.SPANISH, Locales.ARABIC], defaultLocale: Locales.ENGLISH, }, }; export default config;Pour la liste complète des options, voir la documentation de configuration.
Déclarez Votre Contenu
Déclarez chaque étiquette que le serveur restituera, y compris celles qui n'apparaissent que dans un fragment :
src/app.content.tsCopier le codeCopier le code dans le presse-papiers
import { insert, t, type Dictionary } from "intlayer"; const appContent = { key: "app", content: { pageTitle: "Intlayer + htmx", localeLabel: t({ fr: "Langue", en: "Language", es: "Idioma", ar: "اللغة", }), cartSummary: insert( t({ fr: "Articles dans votre panier : {{count}}", en: "Items in your cart: {{count}}", es: "Artículos en tu carrito: {{count}}", ar: "المنتجات في سلتك: {{count}}", }) ), addItem: t({ fr: "Ajouter un article", en: "Add an item", es: "Añadir un artículo", ar: "أضف منتجًا", }), }, } satisfies Dictionary; export default appContent;Les déclarations de contenu peuvent se trouver n'importe où sous
contentDir(par défaut./src) et correspondre à.content.{json,ts,tsx,js,jsx,mjs,cjs,md,mdx,yaml,yml}. Consultez la documentation de déclaration de contenu.Enregistrer le middleware Intlayer
Le middleware résout la locale de chaque requête et l'expose à vos handlers.
src/index.tsCopier le codeCopier le code dans le presse-papiers
import cookieParser from "cookie-parser"; import express from "express"; import { intlayer } from "express-intlayer"; const app = express(); // Le cookie parser doit s'exécuter en premier : `express-intlayer` lit la locale // du cookie via `req.cookies`. app.use(cookieParser()); app.use(express.urlencoded({ extended: false })); app.use(intlayer());La locale résolue se trouve sur
res.locals.locale.src/index.tsCopier le codeCopier le code dans le presse-papiers
import cookie from "@fastify/cookie"; import formbody from "@fastify/formbody"; import Fastify from "fastify"; import { intlayer } from "fastify-intlayer"; const fastify = Fastify(); await fastify.register(cookie); await fastify.register(formbody); await fastify.register(intlayer);La locale résolue est sur
req.intlayer.locale.src/index.tsCopier le codeCopier le code dans le presse-papiers
import { Hono } from "hono"; import { intlayer } from "hono-intlayer"; const app = new Hono(); app.use("*", intlayer());La locale résolue est
c.get("locale").src/index.tsCopier le codeCopier le code dans le presse-papiers
import { Elysia } from "elysia"; import { intlayer } from "elysia-intlayer"; const app = new Elysia().use(intlayer());La locale résolue est
intlayer!.localesur le contexte de la route.Par défaut, la locale est extraite du cookie
INTLAYER_LOCALE, puis de l'en-têtex-intlayer-locale, puis de la négociationAccept-Language.Rendre des fragments avec la locale de la requête
Écrivez vos renderers de fragment comme des fonctions pures d'une locale, et passez la locale que le middleware a résolu. La passer explicitement lie un fragment à la requête qui l'a demandé, quel que soit le serveur sur lequel vous êtes.
src/views.tsCopier le codeCopier le code dans le presse-papiers
import { currency, getIntlayer, type Locale } from "intlayer"; const HTML_ENTITIES: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }; /** Échappe une valeur traduite pour qu'elle ne puisse pas s'échapper du markup. */ const escapeHtml = (value: string): string => value.replace( /[&<>"']/g, (character) => HTML_ENTITIES[character] ?? character ); export const renderCart = (locale: Locale, itemCount: number): string => { const content = getIntlayer("app", locale); return `<section id="cart"> <p>${escapeHtml(String(content.cartSummary({ count: itemCount })))}</p> <p>${escapeHtml(currency(itemCount * 12.5, { locale, currency: "EUR" }))}</p> <button hx-post="/cart/items" hx-vals='{"itemCount": ${itemCount}}' hx-target="#cart" hx-swap="outerHTML" >${escapeHtml(String(content.addItem))}</button> </section>`; };Le servir à partir d'une route :
src/index.tsCopier le codeCopier le code dans le presse-papiers
app.post("/cart/items", (req, res) => { const itemCount = Number(req.body?.itemCount ?? 0) + 1; res.type("html").send(renderCart(res.locals.locale, itemCount)); });src/index.tsCopier le codeCopier le code dans le presse-papiers
fastify.post("/cart/items", async (req, reply) => { const itemCount = Number((req.body as { itemCount?: string })?.itemCount ?? 0) + 1; return reply .type("text/html") .send(renderCart(req.intlayer.locale, itemCount)); });src/index.tsCopier le codeCopier le code dans le presse-papiers
app.post("/cart/items", async (c) => { const body = await c.req.parseBody(); const itemCount = Number(body["itemCount"] ?? 0) + 1; return c.html(renderCart(c.get("locale"), itemCount)); });src/index.tsCopier le codeCopier le code dans le presse-papiers
app.post("/cart/items", ({ body, intlayer }) => { const itemCount = Number((body as { itemCount?: string })?.itemCount ?? 0) + 1; return new Response(renderCart(intlayer!.locale, itemCount), { headers: { "content-type": "text/html" }, }); });Le même fragment répond maintenant en français pour un visiteur dont le cookie indique
fr, et en arabe pour celui dont le cookie indiquear, sans aucun changement au markup appelant.Servir la première page
Rendu du
<body>seul, de sorte que le commutateur de locale à l'étape 7 puisse le remplacer entièrement, puis envelopper-le dans le document qui charge htmx :src/views.tsCopier le codeCopier le code dans le presse-papiers
import { getHTMLTextDir, getIntlayer, type Locale } from "intlayer"; export const renderBody = (locale: Locale, itemCount: number): string => { // Récupère le contenu internationalisé pour la locale donnée const content = getIntlayer("app", locale); return `<body lang="${locale}" dir="${getHTMLTextDir(locale)}"> <main> <h1>${escapeHtml(String(content.pageTitle))}</h1> ${renderLocaleSwitcher(locale)} ${renderCart(locale, itemCount)} </main> </body>`; }; export const renderPage = (locale: Locale, itemCount: number): string => `<!doctype html> <html lang="${locale}" dir="${getHTMLTextDir(locale)}"> <head> <meta charset="utf-8" /> <title>${escapeHtml(String(getIntlayer("app", locale).pageTitle))}</title> <script src="https://unpkg.com/htmx.org@2.0.4"></script> </head> ${renderBody(locale, itemCount)} </html>`;getHTMLTextDirretourneltr,rtlouautopour la locale, ce qui permet à l'arabe et l'hébreu de s'afficher correctement.Changer la langue
Changer de langue est une requête comme une autre. Le serveur stocke le choix dans le cookie que le middleware lit, puis retourne la page rendue dans la nouvelle locale.
Affichez le sélecteur comme un
selectqui s'envoie lui-même et remplace tout le<body>, pour que les étiquettes statiques autour de vos fragments changent aussi :src/views.tsCopier le codeCopier le code dans le presse-papiers
import { getIntlayer, getLocaleName, type Locale, locales } from "intlayer"; const renderLocaleSwitcher = (locale: Locale): string => { const content = getIntlayer("app", locale); const options = locales .map( (availableLocale: Locale) => `<option value="${availableLocale}"${availableLocale === locale ? " selected" : ""}>${escapeHtml(getLocaleName(availableLocale, locale))}</option>` ) .join(""); return `<form> <label for="locale">${escapeHtml(String(content.localeLabel))}</label> <select id="locale" name="locale" hx-post="/locale" hx-trigger="change" hx-target="body" hx-swap="outerHTML" >${options}</select> </form>`; };getLocaleName(availableLocale, locale)écrit chaque langue dans la langue actuellement affichée. Passez aucun deuxième argument pour écrire chacune dans sa propre langue à la place.Gérez la publication en validant la valeur, en définissant le cookie et en renvoyant le nouveau corps :
src/index.tsCopier le codeCopier le code dans le presse-papiers
import { isDeclaredLocale } from "intlayer"; app.post("/locale", (req, res) => { const requestedLocale = String(req.body?.locale); if (!isDeclaredLocale(requestedLocale)) { res.status(400).send("Unknown locale"); return; } res.cookie("INTLAYER_LOCALE", requestedLocale, { sameSite: "lax", path: "/", }); res.type("html").send(renderBody(requestedLocale, 0)); });src/index.tsCopier le codeCopier le code dans le presse-papiers
import { isDeclaredLocale } from "intlayer"; fastify.post("/locale", async (req, reply) => { const requestedLocale = String((req.body as { locale?: string })?.locale); if (!isDeclaredLocale(requestedLocale)) { return reply.status(400).send("Unknown locale"); } return reply .setCookie("INTLAYER_LOCALE", requestedLocale, { sameSite: "lax", path: "/", }) .type("text/html") .send(renderBody(requestedLocale, 0)); });src/index.tsCopier le codeCopier le code dans le presse-papiers
import { setCookie } from "hono/cookie"; import { isDeclaredLocale } from "intlayer"; app.post("/locale", async (c) => { const body = await c.req.parseBody(); const requestedLocale = String(body["locale"]); if (!isDeclaredLocale(requestedLocale)) { return c.text("Locale inconnue", 400); } setCookie(c, "INTLAYER_LOCALE", requestedLocale, { sameSite: "Lax", path: "/", }); return c.html(renderBody(requestedLocale, 0)); });src/index.tsCopier le codeCopier le code dans le presse-papiers
import { isDeclaredLocale } from "intlayer"; app.post("/locale", ({ body, cookie, status }) => { const requestedLocale = String((body as { locale?: string })?.locale); if (!isDeclaredLocale(requestedLocale)) { return status(400, "Unknown locale"); } cookie["INTLAYER_LOCALE"]!.set({ value: requestedLocale, sameSite: "lax", path: "/", }); return new Response(renderBody(requestedLocale, 0), { headers: { "content-type": "text/html" }, }); });isDeclaredLocalerestreint une chaîne arbitraire à l'une de vos locales configurées, donc une valeur inattendue ne atteint jamais vos renderers.Garder lang et dir synchronisés après un swap
FacultatifUn échange peut remplacer le
<body>, jamais le<html>qui l'entoure. Affichezlangetdirsur le corps échangé et copiez-les sur l'élément racine une fois, à partir de la tête :src/views.tsCopier le codeCopier le code dans le presse-papiers
Sans cela, un passage à l'arabe s'affiche de droite à gauche dans le corps tandis que le document annonce toujours la langue précédente aux technologies d'assistance et aux crawlers.
Envoyer la locale comme en-tête au lieu d'un cookie
FacultatifSi un cookie ne vous convient pas, attachez la locale à chaque requête htmx avec
hx-headerssur un élément ancêtre. Les descendants l'hériteront :htmlCopier le codeCopier le code dans le presse-papiers
Le middleware lit
x-intlayer-localepar défaut. Vous pouvez renommer les deux carriers dans votre configuration :intlayer.config.tsCopier le codeCopier le code dans le presse-papiers
import { Locales, type IntlayerConfig } from "intlayer"; const config: IntlayerConfig = { // ... Autres options de configuration routing: { storage: [ { type: "header", name: "my-locale-header" }, { type: "cookie", name: "my-locale-cookie" }, ], }, }; export default config;
Configurer TypeScript
Incluez les types générés automatiquement afin qu'une clé non déclarée soit une erreur de compilation plutôt qu'une chaîne vide à l'exécution.
Copier le code dans le presse-papiers
Configuration Git
Il est recommandé d'ignorer les fichiers générés par Intlayer :
Copier le code dans le presse-papiers
Extension VS Code
Pour améliorer votre expérience de développement avec Intlayer, vous pouvez installer l'extension officielle Intlayer VS Code Extension.
Installer depuis la VS Code Marketplace
Cette extension fournit :
- Autocomplétion pour les clés de traduction.
- Détection d'erreurs en temps réel pour les traductions manquantes.
- Aperçus intégrés du contenu traduit.
- Actions rapides pour créer et mettre à jour facilement les traductions.
Pour plus de détails sur la façon d'utiliser l'extension, consultez la documentation de l'extension Intlayer VS Code.
Aller plus loin
Pour aller plus loin, vous pouvez externaliser votre contenu en utilisant le CMS, afin que les traducteurs puissent modifier le contenu sans déploiement.
Questions fréquemment posées
Parce que la requête de fragment n'a pas transporté de locale. Les requêtes htmx sont indépendantes de la page qui les a émises, donc la locale doit voyager sur chacune d'entre elles, via le cookie INTLAYER_LOCALE ou un header x-intlayer-locale défini avec hx-headers. Vérifiez que le parser de cookie s'exécute avant le middleware Intlayer sur Express et Fastify, sinon le cookie n'est jamais lu et chaque requête revient à Accept-Language.
Passez-la. Les intégrations exposent la locale résolue (res.locals.locale, req.intlayer.locale, c.get("locale"), intlayer!.locale), et la passer à getIntlayer fait de chaque renderer une fonction pure d'une locale. C'est plus facile à tester, et cela garde vos renderers de fragments portables si vous changez de serveur.
Non. Tout ce qu'un visiteur voit est produit par le serveur, donc il n'y a rien à traduire dans le navigateur. C'est aussi pourquoi le coût du poids de la page pour l'i18n dans une app htmx est proche de zéro : aucun catalogue n'est jamais expédié vers le client.
Servez vos pages sous un préfixe de locale (/fr/cart) et lisez la locale à partir du chemin dans votre gestionnaire de route, plutôt que depuis le cookie, pour le rendu complet de la page. Les fragments peuvent continuer à utiliser le cookie ou l'en-tête. Voir configuration pour les options de routage et réécriture d'URL personnalisée.
getHTMLTextDir(locale) retourne ltr, rtl ou auto. Définissez-le sur le document pour le rendu initial, et réappliquez-le après un échange comme le montre l'étape 8. Utilisez les propriétés CSS logiques (margin-inline-start plutôt que margin-left) afin que votre mise en page suive.
Oui, pour tout ce que vous interpolez dans une chaîne de template, exactement comme pour toute autre valeur dynamique. Le contenu provenant du CMS ou d'un traducteur n'est pas du markup que vous contrôlez. L'étape 5 montre un échappeur minimal.
