Автор:
    Дата створення:2026-08-29Останнє оновлення:2026-08-29

    Перекладіть вашу htmx програму за допомогою Intlayer | Internationalization (i18n)

    htmx не отримує власного контенту. Кожен напис, який читає відвідувач, — це HTML, який виробив ваш сервер, і кожна заміна є окремим HTTP-запитом. Інтернаціоналізація htmx додатка — це тому серверна справа: локаль повинна бути визначена на кожному запиті, і кожен фрагмент повинен бути відрендерений цією мовою.

    Intlayer охоплює це через свої backend інтеграції, які виявляють локаль для кожного запиту і показують ваш оголошений контент обробнику, який формує HTML.

    Зміст

    Три правила i18n у htmx додатку

    Одна сторінка може ініціювати десятки swap'ів. Кожен з них — це свіжий запит без пам'яті про сторінку, яка його видала. Якщо локаль живе у змінній, встановленій під час початкового рендерингу, кожен фрагмент після нього повертається до мови за замовчуванням.

    Middleware Intlayer розв'язує локаль із самого запиту, тому фрагмент, поданий на десятій хвилині, відповідає тією ж мовою, що й сторінка, подана на нульовій хвилині.

    Два переносники працюють з htmx. Cookie (INTLAYER_LOCALE) автоматично відправляється браузером при кожному запиті, включаючи запити htmx. Заголовок (x-intlayer-locale) може бути прикріплений до запитів htmx за допомогою атрибута hx-headers. Обидва читаються за замовчуванням.

    Перекладене значення, інтерпольоване у фрагмент, є розміткою. Екранізуйте його точно так само, як ви робили б з будь-яким іншим динамічним значенням, тому переклад, що містить <, не може розірвати документ, у який він вставляється.


    Покрокове керівництво

    ide.intlayer.org

    Див. Application Template на GitHub.

    1. Встановлення залежностей

      Встановіть intlayer плюс інтеграцію для вашого сервера.

      bash
      npm install intlayer express-intlayer cookie-parser
      
      bash
      npm install intlayer fastify-intlayer @fastify/cookie @fastify/formbody
      
      bash
      npm install intlayer hono-intlayer
      
      bash
      npm install intlayer elysia-intlayer
      
      bash
      bun add intlayer elysia-intlayer
      
      Express та Fastify читають куки локалі через власні парсери cookies, тому їх потрібно встановити разом. Hono та Elysia розбирають cookies нативно.

      htmx сам по собі - це один тег скрипту, який додається на кроці 4.

    2. Конфігурація вашого проекту

      Створіть intlayer.config.ts у кореневі вашого проекту:

      intlayer.config.ts
      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;
      
      Для повного списку параметрів див. документацію конфігурації.
    3. Оголосити ваш вміст

      Оголосіть кожен label, який сервер буде відображати, включаючи ті, що з'являються тільки всередині фрагмента:

      src/app.content.ts
      import { insert, t, type Dictionary } from "intlayer";
      
      const appContent = {
        key: "app",
        content: {
          pageTitle: "Intlayer + htmx",
      
          localeLabel: t({
            uk: "Мова",
            en: "Language",
            fr: "Langue",
            es: "Idioma",
            ar: "اللغة",
          }),
      
          cartSummary: insert(
            t({
              uk: "Товари у вашому кошику: {{count}}",
              en: "Items in your cart: {{count}}",
              fr: "Articles dans votre panier : {{count}}",
              es: "Artículos en tu carrito: {{count}}",
              ar: "المنتجات في سلتك: {{count}}",
            })
          ),
      
          addItem: t({
            uk: "Додати товар",
            en: "Add an item",
            fr: "Ajouter un article",
            es: "Añadir un artículo",
            ar: "أضف منتجًا",
          }),
        },
      } satisfies Dictionary;
      
      export default appContent;
      
      Оголошення контенту можуть знаходитися будь-де під contentDir (за замовчуванням ./src) та відповідати .content.{json,ts,tsx,js,jsx,mjs,cjs,md,mdx,yaml,yml}. Див. документацію оголошення контенту.
    4. Зареєструвати middleware Intlayer

      Middleware розпізнає локаль кожного запиту та надає її доступ до ваших обробників.

      src/index.ts
      import cookieParser from "cookie-parser";
      import express from "express";
      import { intlayer } from "express-intlayer";
      
      const app = express();
      
      // The cookie parser has to run first: `express-intlayer` reads the locale
      // cookie through `req.cookies`.
      app.use(cookieParser());
      app.use(express.urlencoded({ extended: false }));
      app.use(intlayer());
      

      Розпізнана локаль знаходиться на res.locals.locale.

      src/index.ts
      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);
      

      Розпізнана локаль знаходиться на req.intlayer.locale.

      src/index.ts
      import { Hono } from "hono";
      import { intlayer } from "hono-intlayer";
      
      const app = new Hono();
      
      app.use("*", intlayer());
      

      Розпізнана локаль — це c.get("locale").

      src/index.ts
      import { Elysia } from "elysia";
      import { intlayer } from "elysia-intlayer";
      
      const app = new Elysia().use(intlayer());
      

      Розраховане locale доступне як intlayer!.locale на контексті маршруту.

      За замовчуванням locale беруть з cookies INTLAYER_LOCALE, потім із заголовка x-intlayer-locale, потім із переговорів Accept-Language.

    5. Рендеризуйте фрагменти з locale запиту

      Напишіть ваші рендери фрагментів як чисті функції locale та передайте locale, яке middleware розрахував. Передання його явно утримує фрагмент прив'язаним до запиту, який його запросив, незалежно від того, на якому сервері ви перебуваєте.

      src/views.ts
      import { currency, getIntlayer, type Locale } from "intlayer";
      
      const HTML_ENTITIES: Record<string, string> = {
        "&": "&amp;",
        "<": "&lt;",
        ">": "&gt;",
        '"': "&quot;",
        "'": "&#39;",
      };
      
      /** Екранує перекладене значення, щоб воно не могло вийти за межі розмітки. */
      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>`;
      };
      

      Подайте його з маршруту:

      src/index.ts
      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.ts
      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.ts
      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.ts
      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" },
        });
      });
      

      Той же фрагмент тепер відповідає французькою мовою для відвідувача, чий cookie говорить fr, і арабською для того, чий cookie говорить ar, без змін у викликаному розмітці.

    6. Служба першої сторінки

      Рендеріть <body> окремо, щоб перемикач мови на кроці 7 міг замінити його цілком, потім обгорніть його в документ, який завантажує htmx:

      src/views.ts
      import { getHTMLTextDir, getIntlayer, type Locale } from "intlayer";
      
      export const renderBody = (locale: Locale, itemCount: number): string => {
        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>`;
      

      getHTMLTextDir повертає ltr, rtl або auto для locale, що забезпечує правильне відображення арабської та іврит мов.

    7. Змінити мову

      Зміна мови — це запит як і будь-який інший. Сервер зберігає вибір у cookie, що його читає middleware, потім повертає сторінку, перевідрендерену на новій локалі.

      Відобразіть перемикач як select, який відправляє себе та замінює весь <body>, щоб статичні мітки навколо ваших фрагментів також змінилися:

      src/views.ts
      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) записує кожну мову мовою, яка зараз відображається. Передайте другий аргумент, щоб замість цього написати кожну мовою її власної мови.

      Обробляйте post, перевіряючи значення, встановлюючи cookie та повертаючи нове тіло:

      src/index.ts
      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.ts
      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.ts
      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("Unknown locale", 400);
        }
      
        // Встановити cookie для локалі
        setCookie(c, "INTLAYER_LOCALE", requestedLocale, {
          sameSite: "Lax",
          path: "/",
        });
        return c.html(renderBody(requestedLocale, 0));
      });
      
      src/index.ts
      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" },
        });
      });
      
      isDeclaredLocale звужує довільний рядок до одного з ваших налаштованих локалей, тому неочікуване значення ніколи не потрапляє до ваших рендерерів.
    8. Синхронізуйте lang і dir після заміни

      Необов'язково

      Обмін може замінити <body>, але ніколи не замінює <html> навколо нього. Рендеріть lang та dir на обміненому body та скопіюйте їх назад на кореневий елемент один раз з head:

      src/views.ts
      <script>
        document.addEventListener("htmx:afterSwap", () => {
          document.documentElement.lang = document.body.lang;
          document.documentElement.dir = document.body.dir;
        });
      </script>
      

      Без цього перемикання на арабську мову рендеритиме справа наліво всередину body, а документ все ще повідомляє попередню мову допоміжним технологіям та краулерам.

    9. Необов'язково

      Якщо cookie вас не влаштовує, додайте локаль до кожного htmx запиту за допомогою hx-headers на елементі-предку. Нащадки успадковують її:

      html
      <body hx-headers='{"x-intlayer-locale": "fr"}'>
        ...
      </body>
      

      Middleware за замовчуванням читає x-intlayer-locale. Ви можете перейменувати обидва носії в конфігурації:

      intlayer.config.ts
      import { Locales, type IntlayerConfig } from "intlayer";
      
      const config: IntlayerConfig = {
        // ... Інші параметри конфігурації
        routing: {
          storage: [
            { type: "header", name: "my-locale-header" },
            { type: "cookie", name: "my-locale-cookie" },
          ],
        },
      };
      
      export default config;
      

    Налаштування TypeScript

    Включіть автоматично згенеровані типи, щоб невизначений ключ був помилкою компіляції, а не порожнім рядком під час виконання.

    tsconfig.json
    {
      // ... Ваші існуючі конфігурації TypeScript
      "include": [
        // ... Ваші існуючі конфігурації TypeScript
        ".intlayer/**/*.ts", // Включіть автоматично згенеровані типи
      ],
    }
    

    Git Configuration

    Рекомендується ігнорувати файли, згенеровані Intlayer:

    .gitignore
    # Ігнорувати файли, згенеровані Intlayer
    .intlayer
    

    VS Code Extension

    Щоб покращити розробку за допомогою Intlayer, ви можете встановити офіційне Intlayer VS Code Extension.

    Встановити з VS Code Marketplace

    Це розширення надає:

    • Автодоповнення для ключів перекладу.
    • Виявлення помилок в реальному часі для відсутніх перекладів.
    • Вбудовані попередні перегляди перекладеного вмісту.
    • Швидкі дії для легкого створення та оновлення перекладів.

    Для отримання більше деталей про використання розширення звертайтесь до документації Intlayer VS Code Extension.


    Йти далі

    Щоб йти далі, ви можете екстерналізувати свій вміст за допомогою CMS, щоб перекладачі змінювали копію без розгортання.

    Часто задавані запитання

    Тому що запит фрагмента не мав локалі. htmx запити незалежні від сторінки, яка їх видала, тому локаль повинна передаватися на кожному з них через cookie INTLAYER_LOCALE або заголовок x-intlayer-locale, встановлений за допомогою hx-headers. Переконайтеся, що парсер cookie запускається перед middleware Intlayer на Express та Fastify, інакше cookie ніколи не читається і кожен запит повертається до Accept-Language.

    Передавайте його. Інтеграції надають розпізнану локаль (res.locals.locale, req.intlayer.locale, c.get("locale"), intlayer!.locale), а передача її до getIntlayer робить кожен renderer чистою функцією локалі. Це простіше тестувати, і це робить ваші fragment renderers портативними, якщо ви зміните server.

    Ні. Все, що бачить відвідувач, створюється сервером, тому в браузері нічого не потрібно перекладати. Саме тому вартість ваги сторінки для i18n в htmx додатку близька до нуля: жоден каталог ніколи не відправляється клієнту.

    Подавайте свої сторінки з префіксом локалі (/fr/cart) і читайте локаль зі шляху у вашому обробнику маршруту, а не з cookie, для повного рендерингу сторінки. Фрагменти можуть продовжити використовувати cookie або заголовок. Див. configuration для параметрів маршрутизації та custom URL rewrites.

    getHTMLTextDir(locale) повертає ltr, rtl або auto. Установіть його на документ для першого рендерингу та переналаштуйте його після заміни, як показано на кроці 8. Використовуйте логічні властивості CSS (margin-inline-start замість margin-left), щоб ваш макет слідував за ними.

    Так, для будь-чого, що ви інтерполюєте в рядок шаблону, точно як і для будь-якого іншого динамічного значення. Вміст, який надходить від CMS або від перекладача, — це не розмітка, яку ви контролюєте. Крок 5 показує мінімальний екранувач.