使用您最喜欢的AI助手总结文档,并引用此页面和AI提供商
版本历史
- "Initial history"v9.4.12026/8/29
此页面的内容已使用 AI 翻译。
查看英文原文的最新版本如果您有改善此文档的想法,请随时通过在GitHub上提交拉取请求来贡献。
文档的 GitHub 链接复制文档 Markdown 到剪贴板
使用 Intlayer 翻译您的 htmx 应用 | 国际化 (i18n)
htmx 不渲染任何自己的内容。访问者读到的每个标签都是您的服务器生成的 HTML,每次交换都是一个单独的 HTTP 请求。因此,国际化 htmx 应用是一个服务器问题:需要在每个请求上解析 locale,并且每个片段都必须以该 locale 呈现。
Intlayer 通过其后端集成涵盖了这一点,这些集成按请求检测 locale 并向构建 HTML 的处理程序暴露您声明的内容。
目录
htmx 应用中 i18n 的三个规则
一个单页面可以触发数十个交换。每一个都是一个新的请求,对发起它的页面没有任何记忆。如果locale存储在初始渲染期间设置的变量中,之后的每个片段都会回退到默认语言。
Intlayer middleware 从请求本身解析locale,因此在第十分钟提供的片段用的是与第零分钟提供的页面相同的语言。
两种方式可与htmx配合使用。浏览器会自动在每个请求(包括htmx请求)上发送cookie(INTLAYER_LOCALE)。可以使用hx-headers属性将header(x-intlayer-locale)附加到htmx请求。默认情况下两者都会被读取。
插入到片段中的翻译值是标记。对其进行转义,就像对待任何其他动态值一样,这样包含 < 的翻译就无法破坏它被交换到的文档。
逐步指南
查看 GitHub 上的应用程序模板。
安装依赖项
安装
intlayer加上适用于你的服务器的集成。bash复制代码复制代码到剪贴板
bash复制代码复制代码到剪贴板
bash复制代码复制代码到剪贴板
bash复制代码复制代码到剪贴板
bash复制代码复制代码到剪贴板
Express 和 Fastify 通过各自的 cookie 解析器读取 locale cookie,因此必须与其一起安装。Hono 和 Elysia 原生解析 cookies。
htmx 本身是一个单独的脚本标签,在第 4 步中添加。
配置您的项目
在项目根目录创建
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;有关完整的选项列表,请参阅配置文档。
声明您的内容
声明服务器将呈现的每个标签,包括仅出现在片段内的标签:
src/app.content.ts复制代码复制代码到剪贴板
import { insert, t, type Dictionary } from "intlayer"; const appContent = { key: "app", content: { pageTitle: "Intlayer + htmx", localeLabel: t({ zh: "语言", en: "Language", fr: "Langue", es: "Idioma", ar: "اللغة", }), cartSummary: insert( t({ zh: "购物车中的商品: {{count}}", en: "Items in your cart: {{count}}", fr: "Articles dans votre panier : {{count}}", es: "Artículos en tu carrito: {{count}}", ar: "المنتجات في سلتك: {{count}}", }) ), addItem: t({ zh: "添加商品", 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}。见 内容声明文档。注册 Intlayer middleware
中间件解决每个请求的 locale,并将其暴露给你的处理程序。
src/index.ts复制代码复制代码到剪贴板
import cookieParser from "cookie-parser"; import express from "express"; import { intlayer } from "express-intlayer"; const app = express(); // cookie 解析器必须首先运行:`express-intlayer` 通过 `req.cookies` 读取 locale cookie。 app.use(cookieParser()); app.use(express.urlencoded({ extended: false })); app.use(intlayer());解决的 locale 在
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);解析后的 locale 在
req.intlayer.locale上。src/index.ts复制代码复制代码到剪贴板
import { Hono } from "hono"; import { intlayer } from "hono-intlayer"; const app = new Hono(); app.use("*", intlayer());解析后的 locale 是
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 是从
INTLAYER_LOCALEcookie 获取的,然后是x-intlayer-localeheader,然后是Accept-Language协商。使用请求 locale 渲染片段
将你的片段渲染器编写为 locale 的纯函数,并传递中间件解析的 locale。显式传递它可以保持片段与请求它的服务器相关联。
src/views.ts复制代码复制代码到剪贴板
import { currency, getIntlayer, type Locale } from "intlayer"; const HTML_ENTITIES: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }; /** 转义已翻译的值,防止其突破标记。 */ 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) => { // 从请求体获取项目数量,如果不存在则默认为0,然后加1 const itemCount = Number(req.body?.itemCount ?? 0) + 1; // 设置响应类型为HTML并发送渲染后的购物车 res.type("html").send(renderCart(res.locals.locale, itemCount)); });src/index.ts复制代码复制代码到剪贴板
fastify.post("/cart/items", async (req, reply) => { // 从请求体获取项目数量,如果不存在则默认为0,然后加1 const itemCount = Number((req.body as { itemCount?: string })?.itemCount ?? 0) + 1; // 设置响应类型为HTML并发送渲染后的购物车 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的访问者用阿拉伯语回答,无需对调用标记进行任何更改。提供第一个页面
单独渲染
<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,这使得阿拉伯语和希伯来语能够正确显示。切换语言
切换语言就像任何其他请求一样。服务器将选择存储在中间件读取的 cookie 中,然后返回以新语言环境重新渲染的页面。
将切换器渲染为一个
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)用当前显示的语言写出每种语言。如果不传第二个参数,则用各自的语言写出。通过验证值、设置 cookie 并返回新的 body 来处理 post 请求:
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: "/", }); // 返回渲染后的HTML 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); // 检查请求的locale是否在声明的locale中 if (!isDeclaredLocale(requestedLocale)) { return status(400, "Unknown locale"); } // 设置INTLAYER_LOCALE cookie cookie["INTLAYER_LOCALE"]!.set({ value: requestedLocale, sameSite: "lax", path: "/", }); // 返回渲染后的body内容,设置content-type为text/html return new Response(renderBody(requestedLocale, 0), { headers: { "content-type": "text/html" }, }); });isDeclaredLocale将任意字符串缩小到你配置的locale之一,因此意外的值永远不会到达你的渲染器。在swap后保持lang和dir同步
可选交换可以替换
<body>,但不会替换它周围的<html>。在交换的 body 上渲染lang和dir,然后从 head 中将它们复制回根元素一次:src/views.ts复制代码复制代码到剪贴板
没有这个脚本,切换到阿拉伯语时会在 body 内渲染从右到左的文本,而文档仍然向辅助技术和爬虫宣传前一种语言。
发送区域设置作为标头而不是 cookie
可选如果 cookie 不适合你,可以使用
hx-headers在祖先元素上将区域设置附加到每个 htmx 请求。后代元素会继承它:html复制代码复制代码到剪贴板
中间件默认读取
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
包含自动生成的类型,使得未声明的键在编译时报错,而不是在运行时返回空字符串。
复制代码到剪贴板
Git 配置
建议忽略由 Intlayer 生成的文件:
复制代码到剪贴板
VS Code 扩展
为了改进你在 Intlayer 中的开发体验,你可以安装官方的 Intlayer VS Code 扩展。
该扩展提供:
- 翻译键的自动完成。
- 实时错误检测,用于检测缺失的翻译。
- 内联预览已翻译内容。
- 快速操作,轻松创建和更新翻译。
有关如何使用该扩展的更多详细信息,请参阅 Intlayer VS Code 扩展文档。
进一步探索
要进一步探索,您可以使用 CMS 外部化您的内容,这样翻译人员可以在不需要部署的情况下更改文案。
常见问题
因为片段请求没有携带 locale。htmx 请求独立于发送它们的页面,所以 locale 必须通过 INTLAYER_LOCALE cookie 或通过 hx-headers 设置的 x-intlayer-locale header 在每个请求上传输。检查 cookie 解析器在 Express 和 Fastify 上的 Intlayer middleware 之前运行,否则 cookie 永远不会被读取,每个请求都会回退到 Accept-Language。
将它传递过去。这些集成公开了解析后的 locale(res.locals.locale、req.intlayer.locale、c.get("locale")、intlayer!.locale),将其传递给 getIntlayer 使每个渲染器成为 locale 的纯函数。这样更容易测试,如果你更换服务器,也能让你的 fragment 渲染器更具可移植性。
不需要。访问者看到的所有内容都是由服务器生成的,所以浏览器中没有任何东西需要翻译。这也是为什么 htmx 应用中 i18n 的页面权重成本接近于零:没有 catalog 会被传送到客户端。
在区域设置前缀下提供您的页面(/fr/cart),并在您的路由处理程序中从路径而不是从 cookie 读取区域设置,以进行完整页面渲染。片段可以继续使用 cookie 或标头。请参阅配置以了解路由选项和自定义 URL 重写。
getHTMLTextDir(locale) 返回 ltr、rtl 或 auto。在初始渲染时将其设置在文档上,并在交换后重新应用,如第 8 步所示。使用 CSS 逻辑属性(例如 margin-inline-start 而不是 margin-left),以便您的布局遵循。
是的,对于您插入到模板字符串中的任何内容,与任何其他动态值完全相同。来自 CMS 或翻译人员的内容不是您控制的标记。第 5 步显示了一个最小的转义器。
