feat(i18n): add Lingui runtime and development tooling

- add SSR-safe I18nProvider, Translate, locale hooks, catalog activation, and typed runtime APIs

- add consumer-owned Lingui configuration helpers and a CLI for locale creation, extraction, compilation, and project discovery

- add catalog read/write services plus a Vite development API plugin scoped to each consuming application

- add the portal-based I18nDevtool, message editor, dark-theme tracking, and standalone Message Studio

- document the consumer workflow and cover runtime, configuration, catalog, CLI, and Devtool behavior with tests
This commit is contained in:
Maofeng
2026-07-29 21:23:00 +08:00
parent 011e7d8115
commit 9e3f1d6b59
39 changed files with 2897 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
import type { IncomingMessage, ServerResponse } from "node:http"
import type { ViteDevServer } from "vite"
import type { UpdateMessageInput } from "../devtool/types"
import { CatalogService } from "./catalog-service.ts"
import { runLinguiCommand } from "./lingui-command.ts"
import {
getLinguiConfigFilename,
getProjectRoot,
readProject,
} from "./project.ts"
const API_PREFIX = "/__i18n"
function sendJson(response: ServerResponse, status: number, body: unknown) {
response.statusCode = status
response.setHeader("content-type", "application/json; charset=utf-8")
response.end(JSON.stringify(body))
}
async function readJsonBody(request: IncomingMessage) {
const chunks: Buffer[] = []
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown
}
function isUpdateMessageInput(value: unknown): value is UpdateMessageInput {
if (!value || typeof value !== "object") {
return false
}
const input = value as Record<string, unknown>
return (
typeof input.catalog === "string" &&
typeof input.id === "string" &&
typeof input.locale === "string" &&
typeof input.translation === "string"
)
}
export function configureI18nApi(
server: ViteDevServer,
projectFilename: string
) {
server.middlewares.use(async (request, response, next) => {
if (!request.url) {
next()
return
}
const url = new URL(request.url, "http://localhost")
if (!url.pathname.startsWith(API_PREFIX)) {
next()
return
}
try {
if (
request.method === "GET" &&
url.pathname === `${API_PREFIX}/project`
) {
const project = await readProject(projectFilename)
sendJson(response, 200, project)
return
}
if (
request.method === "GET" &&
url.pathname === `${API_PREFIX}/messages`
) {
const locale = url.searchParams.get("locale")
if (!locale) {
sendJson(response, 400, { error: "locale is required." })
return
}
const service = new CatalogService(projectFilename)
sendJson(response, 200, await service.getMessages(locale))
return
}
if (
request.method === "PUT" &&
url.pathname === `${API_PREFIX}/messages`
) {
const input = await readJsonBody(request)
if (!isUpdateMessageInput(input)) {
sendJson(response, 400, {
error: "The message update payload is invalid.",
})
return
}
const service = new CatalogService(projectFilename)
sendJson(response, 200, await service.updateMessage(input))
return
}
if (
request.method === "POST" &&
url.pathname.startsWith(`${API_PREFIX}/actions/`)
) {
const action = url.pathname.slice(`${API_PREFIX}/actions/`.length)
if (action !== "extract" && action !== "compile") {
sendJson(response, 404, { error: "Unknown action." })
return
}
const result = await runLinguiCommand(
[
action,
...(action === "compile" ? ["--typescript"] : []),
"--config",
getLinguiConfigFilename(projectFilename),
],
getProjectRoot(projectFilename)
)
sendJson(response, 200, result)
return
}
sendJson(response, 404, { error: "Not found." })
} catch (error) {
sendJson(response, 500, {
error:
error instanceof Error ? error.message : "An unknown error occurred.",
})
}
})
}
@@ -0,0 +1,96 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { getCatalogs } from "@lingui/cli/api"
import { getConfig } from "@lingui/conf"
import { afterEach, describe, expect, it } from "vitest"
import { CatalogService } from "./catalog-service"
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true }))
)
})
describe("CatalogService", () => {
it("reads and updates PO catalogs through Lingui's catalog API", async () => {
const directory = await mkdtemp(join(tmpdir(), "workspace-i18n-"))
temporaryDirectories.push(directory)
const projectFilename = join(directory, "i18n.config.json")
const linguiConfigFilename = join(directory, "lingui.config.ts")
await writeFile(projectFilename, "{}\n")
await writeFile(
linguiConfigFilename,
[
"export default {",
' sourceLocale: "en",',
' locales: ["en", "zh-Hans"],',
" catalogs: [{",
' path: "<rootDir>/locales/{locale}/messages",',
' include: ["<rootDir>/src"],',
" }],",
"}",
"",
].join("\n")
)
const config = getConfig({
configPath: linguiConfigFilename,
cwd: directory,
})
const [catalog] = await getCatalogs(config)
if (!catalog) {
throw new Error("Expected a Lingui catalog.")
}
await catalog.write("en", {
"dashboard.title": {
comments: ["Dashboard heading"],
message: "Dashboard",
origin: [["src/dashboard.tsx", 10]],
translation: "Dashboard",
},
})
await catalog.write("zh-Hans", {
"dashboard.title": {
comments: ["Dashboard heading"],
message: "Dashboard",
origin: [["src/dashboard.tsx", 10]],
translation: "",
},
})
const service = new CatalogService(projectFilename)
const [message] = await service.getMessages("zh-Hans")
expect(message).toMatchObject({
id: "dashboard.title",
missing: true,
source: "Dashboard",
translation: "",
})
const updated = await service.updateMessage({
catalog: message?.catalog ?? "messages",
id: "dashboard.title",
locale: "zh-Hans",
translation: "数据看板",
})
expect(updated).toMatchObject({
missing: false,
translation: "数据看板",
})
expect(await readFile(catalog.getFilename("zh-Hans"), "utf8")).toContain(
'msgstr "数据看板"'
)
})
})
+154
View File
@@ -0,0 +1,154 @@
import { getCatalogs } from "@lingui/cli/api"
import type { CatalogType, MessageType } from "@lingui/conf"
import { getConfig } from "@lingui/conf"
import type { DevtoolMessage, UpdateMessageInput } from "../devtool/types"
import { getLinguiConfigFilename, getProjectRoot } from "./project.ts"
interface CatalogEntry {
catalog: Awaited<ReturnType<typeof getCatalogs>>[number]
key: string
}
async function getCatalogEntries(config: ReturnType<typeof getConfig>) {
const catalogs = await getCatalogs(config)
return catalogs.map<CatalogEntry>((catalog, index) => ({
catalog,
key: catalog.name ?? `catalog-${index + 1}`,
}))
}
function toDevtoolMessage({
catalog,
id,
locale,
sourceEntry,
sourceLocale,
targetEntry,
}: {
catalog: string
id: string
locale: string
sourceEntry: MessageType | undefined
sourceLocale: string
targetEntry: MessageType | undefined
}): DevtoolMessage {
const source =
sourceEntry?.message ??
sourceEntry?.translation ??
(sourceLocale === locale ? targetEntry?.message : undefined) ??
id
const catalogTranslation = targetEntry?.translation ?? ""
const translation =
locale === sourceLocale && catalogTranslation.length === 0
? source
: catalogTranslation
return {
catalog,
comments: targetEntry?.comments ?? sourceEntry?.comments ?? [],
id,
missing: locale !== sourceLocale && translation.length === 0,
obsolete: Boolean(targetEntry?.obsolete ?? sourceEntry?.obsolete),
origins: (sourceEntry?.origin ?? targetEntry?.origin ?? []).map(
([file, line]) => ({ file, line })
),
source,
translation,
}
}
export class CatalogService {
readonly config: ReturnType<typeof getConfig>
readonly catalogs: Promise<readonly CatalogEntry[]>
constructor(projectFilename: string) {
this.config = getConfig({
configPath: getLinguiConfigFilename(projectFilename),
cwd: getProjectRoot(projectFilename),
})
this.catalogs = getCatalogEntries(this.config)
}
async getMessages(locale: string) {
this.assertLocale(locale)
const catalogs = await this.catalogs
const messages = await Promise.all(
catalogs.map(async ({ catalog, key }) => {
const [sourceCatalog = {}, targetCatalog = {}] = await Promise.all([
catalog.read(this.config.sourceLocale),
catalog.read(locale),
])
const ids = new Set([
...Object.keys(sourceCatalog),
...Object.keys(targetCatalog),
])
return Array.from(ids)
.sort((left, right) => left.localeCompare(right))
.map((id) =>
toDevtoolMessage({
catalog: key,
id,
locale,
sourceEntry: sourceCatalog[id],
sourceLocale: this.config.sourceLocale,
targetEntry: targetCatalog[id],
})
)
})
)
return messages.flat()
}
async updateMessage(input: UpdateMessageInput) {
this.assertLocale(input.locale)
const catalogs = await this.catalogs
const entry = catalogs.find((candidate) => candidate.key === input.catalog)
if (!entry) {
throw new Error(`Unknown catalog: ${input.catalog}`)
}
const [sourceCatalog = {}, targetCatalog = {}] = await Promise.all([
entry.catalog.read(this.config.sourceLocale),
entry.catalog.read(input.locale),
])
const sourceEntry = sourceCatalog[input.id]
const currentEntry = targetCatalog[input.id]
if (!sourceEntry && !currentEntry) {
throw new Error(`Unknown message: ${input.id}`)
}
const nextCatalog: CatalogType = {
...targetCatalog,
[input.id]: {
...sourceEntry,
...currentEntry,
translation: input.translation,
},
}
await entry.catalog.write(input.locale, nextCatalog)
return toDevtoolMessage({
catalog: entry.key,
id: input.id,
locale: input.locale,
sourceEntry,
sourceLocale: this.config.sourceLocale,
targetEntry: nextCatalog[input.id],
})
}
private assertLocale(locale: string) {
if (!this.config.locales.includes(locale)) {
throw new Error(`Unknown locale: ${locale}`)
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import { join } from "node:path"
import react from "@vitejs/plugin-react"
import open from "open"
import { createServer } from "vite"
import { i18n } from "../vite.ts"
import {
getPackageRoot,
getProjectRoot,
resolveProjectFilename,
} from "./project"
export interface StartDevtoolOptions {
host?: string
open?: boolean
port?: number
project?: string
}
export async function startDevtool({
host = "localhost",
open: shouldOpen = true,
port = 4174,
project,
}: StartDevtoolOptions = {}) {
const projectFilename = await resolveProjectFilename(project)
const packageRoot = getPackageRoot()
const appRoot = join(packageRoot, "src/devtool/app")
const server = await createServer({
configFile: false,
plugins: [react(), i18n({ project: projectFilename })],
root: appRoot,
server: {
fs: {
allow: [packageRoot, getProjectRoot(projectFilename)],
},
host,
port,
},
})
await server.listen()
server.printUrls()
const address = server.httpServer?.address()
const resolvedPort =
address && typeof address === "object" ? address.port : port
const browserHost = host === "0.0.0.0" || host === "::" ? "localhost" : host
const url = `http://${browserHost}:${resolvedPort}`
if (shouldOpen) {
await open(url)
}
return server
}
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bun
import { cac } from "cac"
import { startDevtool } from "./dev-server"
import { runLinguiCommand } from "./lingui-command"
import { addLocale } from "./new-locale"
import {
getLinguiConfigFilename,
getProjectRoot,
resolveProjectFilename,
} from "./project"
const cli = cac("workspace-i18n")
async function runProjectCommand(
command: "compile" | "extract",
projectOption?: string
) {
const projectFilename = await resolveProjectFilename(projectOption)
const result = await runLinguiCommand(
[
command,
...(command === "compile" ? ["--typescript"] : []),
"--config",
getLinguiConfigFilename(projectFilename),
],
getProjectRoot(projectFilename)
)
if (result.output.trim()) {
process.stdout.write(result.output)
}
}
function reportError(error: unknown) {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
}
cli
.command("new <locale>", "Add a BCP-47 locale and create its Lingui catalog")
.option("--project <path>", "Path to i18n.config.json")
.action(async (locale: string, options: { project?: string }) => {
try {
const result = await addLocale(locale, options)
if (result.output.trim()) {
process.stdout.write(result.output)
}
console.log(
result.created
? `Added ${result.locale} to ${result.projectFilename}.`
: `${result.locale} already exists; its catalog was refreshed.`
)
} catch (error) {
reportError(error)
}
})
cli
.command("extract", "Extract messages for the current application")
.option("--project <path>", "Path to i18n.config.json")
.action(async (options: { project?: string }) => {
try {
await runProjectCommand("extract", options.project)
} catch (error) {
reportError(error)
}
})
cli
.command("compile", "Compile catalogs for the current application")
.option("--project <path>", "Path to i18n.config.json")
.action(async (options: { project?: string }) => {
try {
await runProjectCommand("compile", options.project)
} catch (error) {
reportError(error)
}
})
cli
.command("ui", "Start the message customization website")
.option("--project <path>", "Path to i18n.config.json")
.option("--host <host>", "Host to listen on", {
default: "localhost",
})
.option("--port <port>", "Port to listen on", {
default: 4174,
})
.option("--no-open", "Do not open the browser")
.action(
async (options: {
host?: string
open?: boolean
port?: number | string
project?: string
}) => {
try {
await startDevtool({
...options,
port: Number(options.port ?? 4174),
})
} catch (error) {
reportError(error)
}
}
)
cli.help()
cli.version("0.0.0")
cli.parse()
+46
View File
@@ -0,0 +1,46 @@
import { spawn } from "node:child_process"
import { fileURLToPath } from "node:url"
const LINGUI_BIN = fileURLToPath(
new URL("./lingui.js", import.meta.resolve("@lingui/cli"))
)
export interface LinguiCommandResult {
output: string
}
export async function runLinguiCommand(
args: readonly string[],
cwd: string
): Promise<LinguiCommandResult> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [LINGUI_BIN, ...args], {
cwd,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
})
let output = ""
child.stdout.setEncoding("utf8")
child.stderr.setEncoding("utf8")
child.stdout.on("data", (chunk: string) => {
output += chunk
})
child.stderr.on("data", (chunk: string) => {
output += chunk
})
child.on("error", reject)
child.on("close", (code) => {
if (code === 0) {
resolve({ output })
return
}
reject(
new Error(
output.trim() || `Lingui exited with status ${code ?? "unknown"}.`
)
)
})
})
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest"
import { canonicalizeLocale } from "./new-locale"
describe("canonicalizeLocale", () => {
it("normalizes BCP-47 locale casing", () => {
expect(canonicalizeLocale("zh-hans")).toBe("zh-Hans")
expect(canonicalizeLocale("pt-br")).toBe("pt-BR")
})
it("rejects invalid locale identifiers", () => {
expect(() => canonicalizeLocale("not_a_locale")).toThrow(
"not a valid BCP-47 locale"
)
})
})
+65
View File
@@ -0,0 +1,65 @@
import {
getLinguiConfigFilename,
getProjectRoot,
readProject,
resolveProjectFilename,
writeProject,
} from "./project"
import { runLinguiCommand } from "./lingui-command"
export interface AddLocaleOptions {
project?: string
}
export function canonicalizeLocale(locale: string) {
try {
return Intl.getCanonicalLocales(locale)[0]
} catch {
throw new Error(
`"${locale}" is not a valid BCP-47 locale, for example en, zh-Hans, or pt-BR.`
)
}
}
export async function addLocale(
localeInput: string,
{ project: projectOption }: AddLocaleOptions = {}
) {
const locale = canonicalizeLocale(localeInput)
const projectFilename = await resolveProjectFilename(projectOption)
const project = await readProject(projectFilename)
const created = !project.locales.includes(locale)
if (created) {
await writeProject(projectFilename, {
...project,
locales: [...project.locales, locale],
})
}
try {
const result = await runLinguiCommand(
[
"extract",
"--locale",
locale,
"--config",
getLinguiConfigFilename(projectFilename),
],
getProjectRoot(projectFilename)
)
return {
created,
locale,
output: result.output,
projectFilename,
}
} catch (error) {
if (created) {
await writeProject(projectFilename, project)
}
throw error
}
}
+46
View File
@@ -0,0 +1,46 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { afterEach, describe, expect, it } from "vitest"
import { resolveProjectFilename } from "./project"
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true }))
)
})
describe("resolveProjectFilename", () => {
it("discovers configuration owned by the consuming application", async () => {
const applicationRoot = await mkdtemp(
join(tmpdir(), "workspace-i18n-consumer-")
)
temporaryDirectories.push(applicationRoot)
const sourceDirectory = join(applicationRoot, "src", "routes")
const projectFilename = join(applicationRoot, "i18n.config.json")
await mkdir(sourceDirectory, { recursive: true })
await writeFile(projectFilename, "{}\n")
await expect(
resolveProjectFilename(undefined, sourceDirectory)
).resolves.toBe(projectFilename)
})
it("does not fall back to configuration inside the i18n package", async () => {
const applicationRoot = await mkdtemp(
join(tmpdir(), "workspace-i18n-unconfigured-")
)
temporaryDirectories.push(applicationRoot)
await expect(
resolveProjectFilename(undefined, applicationRoot)
).rejects.toThrow("Run the command inside a configured application")
})
})
+128
View File
@@ -0,0 +1,128 @@
import { access, readFile, writeFile } from "node:fs/promises"
import { dirname, isAbsolute, join, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import type { I18nProjectConfig } from "../config/lingui-config"
const PACKAGE_ROOT = fileURLToPath(new URL("../../", import.meta.url))
const PROJECT_FILENAME = "i18n.config.json"
async function fileExists(filename: string) {
try {
await access(filename)
return true
} catch {
return false
}
}
function validateProjectConfig(
input: unknown,
filename: string
): I18nProjectConfig {
if (!input || typeof input !== "object") {
throw new Error(`${filename} must contain a JSON object.`)
}
const value = input as Record<string, unknown>
if (
typeof value.sourceLocale !== "string" ||
!Array.isArray(value.locales) ||
!value.locales.every((locale) => typeof locale === "string") ||
typeof value.catalogPath !== "string" ||
!Array.isArray(value.include) ||
!value.include.every((pattern) => typeof pattern === "string") ||
(value.exclude !== undefined &&
(!Array.isArray(value.exclude) ||
!value.exclude.every((pattern) => typeof pattern === "string")))
) {
throw new Error(
`${filename} does not match the @workspace/i18n project schema.`
)
}
return {
catalogPath: value.catalogPath,
exclude: value.exclude as string[] | undefined,
include: value.include,
locales: value.locales,
sourceLocale: value.sourceLocale,
}
}
async function findInParents(startDirectory: string) {
let directory = resolve(startDirectory)
while (true) {
const candidate = join(directory, PROJECT_FILENAME)
if (await fileExists(candidate)) {
return candidate
}
const parent = dirname(directory)
if (parent === directory) {
return undefined
}
directory = parent
}
}
export async function resolveProjectFilename(
projectOption?: string,
cwd = process.cwd()
) {
if (projectOption) {
const filename = isAbsolute(projectOption)
? projectOption
: resolve(cwd, projectOption)
if (!(await fileExists(filename))) {
throw new Error(`Project configuration not found: ${filename}`)
}
return filename
}
const localProject = await findInParents(cwd)
if (localProject) {
return localProject
}
throw new Error(
`Unable to find ${PROJECT_FILENAME} from ${resolve(cwd)}. Run the command inside a configured application or pass --project with its path.`
)
}
export async function readProject(projectFilename: string) {
const content = await readFile(projectFilename, "utf8")
return validateProjectConfig(JSON.parse(content), projectFilename)
}
export async function writeProject(
projectFilename: string,
project: I18nProjectConfig
) {
await writeFile(
projectFilename,
`${JSON.stringify(project, null, 2)}\n`,
"utf8"
)
}
export function getLinguiConfigFilename(projectFilename: string) {
return join(dirname(projectFilename), "lingui.config.ts")
}
export function getProjectRoot(projectFilename: string) {
return dirname(projectFilename)
}
export function getPackageRoot() {
return PACKAGE_ROOT
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest"
import { createLinguiConfig } from "./lingui-config"
describe("createLinguiConfig", () => {
it("maps the editable project manifest to a Lingui configuration", () => {
expect(
createLinguiConfig({
catalogPath: "locales/{locale}/messages",
exclude: ["**/*.test.ts"],
include: ["src"],
locales: ["en", "zh-Hans"],
sourceLocale: "en",
})
).toEqual({
catalogs: [
{
exclude: ["<rootDir>/**/*.test.ts"],
include: ["<rootDir>/src"],
path: "<rootDir>/locales/{locale}/messages",
},
],
locales: ["en", "zh-Hans"],
runtimeConfigModule: {
Trans: ["@workspace/i18n", "Translate"],
},
sourceLocale: "en",
})
})
})
+36
View File
@@ -0,0 +1,36 @@
import { isAbsolute } from "node:path"
import type { LinguiConfig } from "@lingui/conf"
export interface I18nProjectConfig {
catalogPath: string
exclude?: readonly string[]
include: readonly string[]
locales: readonly string[]
sourceLocale: string
}
function resolveFromProjectRoot(pattern: string) {
if (isAbsolute(pattern) || pattern.includes("<rootDir>")) {
return pattern
}
return `<rootDir>/${pattern.replace(/^\.\//, "")}`
}
export function createLinguiConfig(project: I18nProjectConfig): LinguiConfig {
return {
catalogs: [
{
path: resolveFromProjectRoot(project.catalogPath),
include: project.include.map(resolveFromProjectRoot),
exclude: project.exclude?.map(resolveFromProjectRoot),
},
],
locales: [...project.locales],
runtimeConfigModule: {
Trans: ["@workspace/i18n", "Translate"],
},
sourceLocale: project.sourceLocale,
}
}
+31
View File
@@ -0,0 +1,31 @@
export type DevtoolAction = "compile" | "extract"
interface ApiError {
error?: string
}
export async function requestDevtoolJson<T>(
url: string,
init?: RequestInit
): Promise<T> {
const response = await fetch(url, init)
const body = (await response.json()) as T & ApiError
if (!response.ok) {
throw new Error(body.error ?? `Request failed with ${response.status}.`)
}
return body
}
export async function runDevtoolAction(
action: DevtoolAction,
baseUrl = "/__i18n"
) {
const normalizedBaseUrl = baseUrl.replace(/\/+$/, "")
return requestDevtoolJson<{ output: string }>(
`${normalizedBaseUrl}/actions/${action}`,
{ method: "POST" }
)
}
+58
View File
@@ -0,0 +1,58 @@
import * as React from "react"
import type { I18nProjectConfig } from "../../config/lingui-config"
import { I18nProvider } from "../../runtime"
import { requestDevtoolJson } from "../api-client"
import { I18nDevtool } from "../i18n-devtool"
type ProjectState =
| { status: "loading" }
| { error: Error; status: "error" }
| { project: I18nProjectConfig; status: "ready" }
export function DevtoolApp() {
const [state, setState] = React.useState<ProjectState>({ status: "loading" })
React.useEffect(() => {
requestDevtoolJson<I18nProjectConfig>("/__i18n/project").then(
(project) => setState({ project, status: "ready" }),
(reason) =>
setState({
error:
reason instanceof Error
? reason
: new Error("Unable to load the i18n project."),
status: "error",
})
)
}, [])
if (state.status !== "ready") {
return (
<main
className="devtool-app__state"
role={state.status === "error" ? "alert" : "status"}
>
{state.status === "error"
? state.error.message
: "Loading I18n Devtool…"}
</main>
)
}
const locale = state.project.locales[0]
if (!locale) {
return (
<main className="devtool-app__state">
Add a locale with <code>workspace-i18n new &lt;locale&gt;</code>.
</main>
)
}
return (
<I18nProvider locale={locale} locales={state.project.locales}>
<I18nDevtool defaultOpen mode="standalone" />
</I18nProvider>
)
}
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<meta name="color-scheme" content="light dark" />
<title>Message Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { DevtoolApp } from "./app"
import "./styles.css"
const root = document.getElementById("root")
if (!root) {
throw new Error("Missing #root element.")
}
createRoot(root).render(
<StrictMode>
<DevtoolApp />
</StrictMode>
)
+26
View File
@@ -0,0 +1,26 @@
:root {
color-scheme: light dark;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
body {
min-width: 20rem;
min-height: 100vh;
margin: 0;
}
.devtool-app__state {
display: grid;
min-height: 100vh;
place-items: center;
padding: 2rem;
color: color-mix(in srgb, currentColor 60%, transparent);
text-align: center;
}
@@ -0,0 +1,53 @@
import type {
DevtoolMessage,
MessageRepository,
UpdateMessageInput,
} from "./types"
interface ApiError {
error?: string
}
async function readResponse<T>(response: Response): Promise<T> {
if (response.ok) {
return response.json() as Promise<T>
}
let message = `Request failed with status ${response.status}`
try {
const body = (await response.json()) as ApiError
message = body.error ?? message
} catch {
// Keep the status-based error when the response is not JSON.
}
throw new Error(message)
}
export function createHttpMessageRepository(
baseUrl = "/__i18n"
): MessageRepository {
const normalizedBaseUrl = baseUrl.replace(/\/+$/, "")
return {
async getMessages(locale) {
const response = await fetch(
`${normalizedBaseUrl}/messages?locale=${encodeURIComponent(locale)}`
)
return readResponse<readonly DevtoolMessage[]>(response)
},
async updateMessage(input: UpdateMessageInput) {
const response = await fetch(`${normalizedBaseUrl}/messages`, {
body: JSON.stringify(input),
headers: {
"content-type": "application/json",
},
method: "PUT",
})
return readResponse<DevtoolMessage>(response)
},
}
}
+236
View File
@@ -0,0 +1,236 @@
.i18n-devtool {
--i18n-devtool-background: #f7f7f8;
--i18n-devtool-border: rgb(15 23 42 / 14%);
--i18n-devtool-foreground: #18181b;
--i18n-devtool-muted: #71717a;
--i18n-devtool-panel: #fff;
--i18n-devtool-primary: #2563eb;
position: fixed;
z-index: 2147483647;
color: var(--i18n-devtool-foreground);
color-scheme: light;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
.i18n-devtool[data-dark] {
--i18n-devtool-background: #111318;
--i18n-devtool-border: rgb(255 255 255 / 14%);
--i18n-devtool-foreground: #f4f4f5;
--i18n-devtool-muted: #a1a1aa;
--i18n-devtool-panel: #181a20;
--i18n-devtool-primary: #60a5fa;
color-scheme: dark;
}
.i18n-devtool *,
.i18n-devtool *::before,
.i18n-devtool *::after {
box-sizing: border-box;
}
.i18n-devtool button,
.i18n-devtool select {
color: inherit;
font: inherit;
}
.i18n-devtool__trigger {
position: fixed;
right: 1rem;
bottom: 1rem;
display: inline-flex;
height: 2.75rem;
align-items: center;
gap: 0.45rem;
padding: 0 0.85rem;
border: 1px solid var(--i18n-devtool-border);
border-radius: 999px;
background: var(--i18n-devtool-panel);
box-shadow: 0 12px 30px rgb(0 0 0 / 18%);
cursor: pointer;
font-size: 0.8125rem;
font-weight: 650;
}
.i18n-devtool__trigger span:first-child {
display: grid;
width: 1.5rem;
height: 1.5rem;
place-items: center;
border-radius: 0.4rem;
background: var(--i18n-devtool-primary);
color: white;
font-size: 0.75rem;
}
.i18n-devtool__panel {
position: fixed;
top: 1rem;
right: 1rem;
bottom: 1rem;
display: grid;
width: min(54rem, calc(100vw - 2rem));
overflow: hidden;
grid-template-rows: auto auto minmax(0, 1fr);
border: 1px solid var(--i18n-devtool-border);
border-radius: 1rem;
outline: none;
background: var(--i18n-devtool-background);
box-shadow: 0 24px 70px rgb(0 0 0 / 28%);
}
.i18n-devtool[data-mode="standalone"] {
inset: 0;
}
.i18n-devtool[data-mode="standalone"] .i18n-devtool__panel {
inset: 0;
width: 100vw;
border: 0;
border-radius: 0;
box-shadow: none;
}
.i18n-devtool__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--i18n-devtool-border);
background: var(--i18n-devtool-panel);
}
.i18n-devtool__header h2,
.i18n-devtool__eyebrow {
margin: 0;
}
.i18n-devtool__header h2 {
font-size: 1rem;
}
.i18n-devtool__eyebrow {
margin-bottom: 0.1rem;
color: var(--i18n-devtool-muted);
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.i18n-devtool__controls {
display: flex;
align-items: end;
gap: 0.4rem;
}
.i18n-devtool__controls label {
display: grid;
gap: 0.2rem;
}
.i18n-devtool__controls label span {
color: var(--i18n-devtool-muted);
font-size: 0.625rem;
font-weight: 650;
text-transform: uppercase;
}
.i18n-devtool__controls select,
.i18n-devtool__controls button {
height: 2.25rem;
padding: 0 0.7rem;
border: 1px solid var(--i18n-devtool-border);
border-radius: 0.55rem;
background: var(--i18n-devtool-panel);
}
.i18n-devtool__controls button {
cursor: pointer;
}
.i18n-devtool__controls button:disabled {
cursor: default;
opacity: 0.45;
}
.i18n-devtool__controls .i18n-devtool__close {
width: 2.25rem;
padding: 0;
font-size: 1.25rem;
}
.i18n-devtool__output {
max-height: 10rem;
overflow: auto;
margin: 0;
padding: 0.65rem 1rem;
border-bottom: 1px solid var(--i18n-devtool-border);
background: color-mix(
in srgb,
var(--i18n-devtool-primary) 10%,
var(--i18n-devtool-panel)
);
font-size: 0.72rem;
white-space: pre-wrap;
}
.i18n-devtool__output[data-error] {
background: color-mix(in srgb, #ef4444 12%, var(--i18n-devtool-panel));
color: #ef4444;
}
.i18n-devtool__main {
min-height: 0;
overflow: auto;
}
.i18n-devtool .i18n-message-panel {
--i18n-border: var(--i18n-devtool-border);
--i18n-muted: var(--i18n-devtool-muted);
--i18n-surface: var(--i18n-devtool-panel);
}
.i18n-devtool .i18n-message-panel__toolbar {
background: color-mix(
in srgb,
var(--i18n-devtool-background) 90%,
transparent
);
}
.i18n-devtool .i18n-message-panel input[type="search"],
.i18n-devtool .i18n-message-panel textarea,
.i18n-devtool .i18n-message-panel button {
background: var(--i18n-devtool-panel);
}
@media (max-width: 42rem) {
.i18n-devtool__panel {
inset: 0;
width: 100vw;
border-radius: 0;
}
.i18n-devtool__header {
align-items: stretch;
flex-direction: column;
}
.i18n-devtool__controls {
flex-wrap: wrap;
}
.i18n-devtool__controls label {
min-width: 10rem;
flex: 1;
}
}
@@ -0,0 +1,42 @@
// @vitest-environment jsdom
import * as React from "react"
import { act, render, screen, waitFor } from "@testing-library/react"
import { afterEach, describe, expect, it } from "vitest"
import { I18nProvider } from "../runtime"
import { I18nDevtool } from "./i18n-devtool"
afterEach(() => {
document.documentElement.classList.remove("ui:dark")
})
describe("I18nDevtool", () => {
it("tracks the configured document dark-mode class", async () => {
render(
<I18nProvider
catalogs={{ en: {} }}
locale="en"
locales={["en", "zh-Hans"]}
>
<I18nDevtool dark={"ui\\:dark"} />
</I18nProvider>
)
const devtool = await screen
.findByText("i18n")
.then((element) =>
element.closest<HTMLElement>('[data-slot="i18n-devtool"]')
)
expect(devtool?.hasAttribute("data-dark")).toBe(false)
act(() => {
document.documentElement.classList.add("ui:dark")
})
await waitFor(() => {
expect(devtool?.hasAttribute("data-dark")).toBe(true)
})
})
})
+240
View File
@@ -0,0 +1,240 @@
import * as React from "react"
import { createPortal } from "react-dom"
import { useLocale, useLocales } from "../runtime"
import { runDevtoolAction, type DevtoolAction } from "./api-client"
import { createHttpMessageRepository } from "./http-message-repository"
import { MessagePanel } from "./message-panel"
import { MessageRepositoryProvider } from "./message-repository"
import "./i18n-devtool.css"
export interface I18nDevtoolProps {
apiBaseUrl?: string
className?: string
dark?: string
defaultOpen?: boolean
mode?: "floating" | "standalone"
}
function matchesDarkSelector(element: Element, dark: string) {
const normalizedDark = dark.replace(/\\:/g, ":")
if (
dark.startsWith(".") ||
dark.startsWith("#") ||
dark.startsWith("[") ||
dark.startsWith(":")
) {
try {
return element.matches(dark)
} catch {
return false
}
}
return element.classList.contains(normalizedDark)
}
function useDarkMode(dark: string | undefined) {
const [isDark, setDark] = React.useState(false)
React.useEffect(() => {
if (!dark) {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
const updateFromSystem = () => setDark(mediaQuery.matches)
updateFromSystem()
mediaQuery.addEventListener("change", updateFromSystem)
return () => mediaQuery.removeEventListener("change", updateFromSystem)
}
const updateFromDocument = () => {
setDark(
matchesDarkSelector(document.documentElement, dark) ||
matchesDarkSelector(document.body, dark)
)
}
updateFromDocument()
const observer = new MutationObserver(updateFromDocument)
observer.observe(document.documentElement, { attributes: true })
observer.observe(document.body, { attributes: true })
return () => observer.disconnect()
}, [dark])
return isDark
}
export function I18nDevtool({
apiBaseUrl = "/__i18n",
className,
dark,
defaultOpen = false,
mode = "floating",
}: I18nDevtoolProps) {
const activeLocale = useLocale()
const locales = useLocales()
const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(
null
)
const [isOpen, setOpen] = React.useState(defaultOpen || mode === "standalone")
const [locale, setLocale] = React.useState(activeLocale)
const [reloadKey, setReloadKey] = React.useState(0)
const [actionState, setActionState] = React.useState<DevtoolAction | "idle">(
"idle"
)
const [actionOutput, setActionOutput] = React.useState("")
const [error, setError] = React.useState("")
const isDark = useDarkMode(dark)
const repository = React.useMemo(
() => createHttpMessageRepository(apiBaseUrl),
[apiBaseUrl]
)
React.useEffect(() => {
setPortalTarget(document.body)
}, [])
React.useEffect(() => {
if (locales.some((definition) => definition.locale === locale)) {
return
}
setLocale(activeLocale)
}, [activeLocale, locale, locales])
React.useEffect(() => {
if (!isOpen || mode === "standalone") {
return
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false)
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [isOpen, mode])
const runAction = async (action: DevtoolAction) => {
setActionState(action)
setActionOutput("")
setError("")
try {
const result = await runDevtoolAction(action, apiBaseUrl)
setActionOutput(result.output.trim() || `${action} completed.`)
setReloadKey((current) => current + 1)
} catch (reason) {
setError(reason instanceof Error ? reason.message : `${action} failed.`)
} finally {
setActionState("idle")
}
}
if (!portalTarget) {
return null
}
const rootClassName = ["i18n-devtool", className].filter(Boolean).join(" ")
return createPortal(
<div
className={rootClassName}
data-dark={isDark || undefined}
data-mode={mode}
data-slot="i18n-devtool"
>
{mode === "floating" && !isOpen && (
<button
type="button"
aria-controls="i18n-devtool-panel"
aria-expanded="false"
className="i18n-devtool__trigger"
onClick={() => setOpen(true)}
>
<span aria-hidden="true"></span>
<span>i18n</span>
</button>
)}
{isOpen && (
<section
id="i18n-devtool-panel"
aria-label="I18n Devtool"
className="i18n-devtool__panel"
role="dialog"
>
<header className="i18n-devtool__header">
<div>
<p className="i18n-devtool__eyebrow">Lingui</p>
<h2>I18n Devtool</h2>
</div>
<div className="i18n-devtool__controls">
<label>
<span>Locale</span>
<select
aria-label="Devtool locale"
value={locale}
onChange={(event) => setLocale(event.target.value)}
>
{locales.map((definition) => (
<option key={definition.locale} value={definition.locale}>
{definition.label ?? definition.locale}
</option>
))}
</select>
</label>
<button
type="button"
disabled={actionState !== "idle"}
onClick={() => void runAction("extract")}
>
{actionState === "extract" ? "Extracting…" : "Extract"}
</button>
<button
type="button"
disabled={actionState !== "idle"}
onClick={() => void runAction("compile")}
>
{actionState === "compile" ? "Compiling…" : "Compile"}
</button>
{mode === "floating" && (
<button
type="button"
aria-label="Close I18n Devtool"
className="i18n-devtool__close"
onClick={() => setOpen(false)}
>
×
</button>
)}
</div>
</header>
{(error || actionOutput) && (
<pre
className="i18n-devtool__output"
data-error={error ? "" : undefined}
>
{error || actionOutput}
</pre>
)}
<main className="i18n-devtool__main">
<MessageRepositoryProvider repository={repository}>
<MessagePanel key={`${locale}:${reloadKey}`} locale={locale} />
</MessageRepositoryProvider>
</main>
</section>
)}
</div>,
portalTarget
)
}
+14
View File
@@ -0,0 +1,14 @@
export { createHttpMessageRepository } from "./http-message-repository"
export { I18nDevtool, type I18nDevtoolProps } from "./i18n-devtool"
export { MessagePanel, type MessagePanelProps } from "./message-panel"
export {
MessageRepositoryProvider,
type MessageRepositoryProviderProps,
useMessageRepository,
} from "./message-repository"
export type {
DevtoolMessage,
MessageOrigin,
MessageRepository,
UpdateMessageInput,
} from "./types"
+218
View File
@@ -0,0 +1,218 @@
.i18n-message-panel {
--i18n-border: color-mix(in srgb, currentColor 14%, transparent);
--i18n-muted: color-mix(in srgb, currentColor 62%, transparent);
--i18n-surface: color-mix(in srgb, Canvas 96%, currentColor 4%);
color: CanvasText;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
.i18n-message-panel *,
.i18n-message-panel *::before,
.i18n-message-panel *::after {
box-sizing: border-box;
}
.i18n-message-panel__toolbar {
position: sticky;
z-index: 2;
top: 0;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-bottom: 1px solid var(--i18n-border);
background: color-mix(in srgb, Canvas 90%, transparent);
backdrop-filter: blur(14px);
}
.i18n-message-panel__search {
min-width: 12rem;
flex: 1;
}
.i18n-message-panel input,
.i18n-message-panel textarea,
.i18n-message-panel button {
color: inherit;
font: inherit;
}
.i18n-message-panel input[type="search"],
.i18n-message-panel textarea {
width: 100%;
border: 1px solid var(--i18n-border);
border-radius: 0.625rem;
outline: none;
background: Canvas;
}
.i18n-message-panel input[type="search"] {
height: 2.5rem;
padding: 0 0.75rem;
}
.i18n-message-panel textarea {
min-height: 5.5rem;
resize: vertical;
padding: 0.75rem;
line-height: 1.5;
}
.i18n-message-panel input[type="search"]:focus,
.i18n-message-panel textarea:focus {
border-color: AccentColor;
box-shadow: 0 0 0 3px color-mix(in srgb, AccentColor 18%, transparent);
}
.i18n-message-panel__toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
white-space: nowrap;
font-size: 0.875rem;
}
.i18n-message-panel__count {
color: var(--i18n-muted);
font-variant-numeric: tabular-nums;
font-size: 0.8125rem;
}
.i18n-message-panel__list {
display: grid;
gap: 0.75rem;
padding: 0.75rem;
}
.i18n-message {
display: grid;
gap: 0.875rem;
padding: 1rem;
border: 1px solid var(--i18n-border);
border-radius: 0.875rem;
background: var(--i18n-surface);
}
.i18n-message[data-missing] {
border-color: color-mix(in srgb, #e8a317 55%, var(--i18n-border));
}
.i18n-message[data-obsolete] {
opacity: 0.65;
}
.i18n-message__header,
.i18n-message__actions,
.i18n-message__meta {
display: flex;
align-items: center;
gap: 0.5rem;
}
.i18n-message__header {
justify-content: space-between;
}
.i18n-message__id {
overflow-wrap: anywhere;
font-size: 0.8125rem;
}
.i18n-message__catalog {
color: var(--i18n-muted);
font-size: 0.75rem;
}
.i18n-message__source p {
margin: 0.25rem 0 0;
line-height: 1.5;
}
.i18n-message__field {
display: grid;
gap: 0.35rem;
}
.i18n-message__label {
color: var(--i18n-muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.i18n-message__meta {
flex-wrap: wrap;
color: var(--i18n-muted);
font-size: 0.75rem;
}
.i18n-message__actions {
justify-content: flex-end;
}
.i18n-message__status {
margin-inline-end: auto;
color: var(--i18n-muted);
font-size: 0.75rem;
}
.i18n-message-panel button {
min-height: 2.25rem;
padding: 0 0.8rem;
border: 1px solid var(--i18n-border);
border-radius: 0.55rem;
background: Canvas;
cursor: pointer;
}
.i18n-message-panel button:hover:not(:disabled) {
background: color-mix(in srgb, Canvas 90%, currentColor 10%);
}
.i18n-message-panel button:disabled {
cursor: default;
opacity: 0.45;
}
.i18n-message-panel__state {
display: grid;
min-height: 14rem;
place-items: center;
align-content: center;
gap: 0.75rem;
padding: 2rem;
color: var(--i18n-muted);
text-align: center;
}
.i18n-message-panel__state p {
margin: 0;
}
.i18n-visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
@media (max-width: 40rem) {
.i18n-message-panel__toolbar {
flex-wrap: wrap;
}
.i18n-message-panel__search {
flex-basis: 100%;
}
}
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
import * as React from "react"
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { MessagePanel } from "./message-panel"
import type { DevtoolMessage, MessageRepository } from "./types"
const message: DevtoolMessage = {
catalog: "messages",
comments: ["Navigation title"],
id: "navigation.home",
missing: true,
obsolete: false,
origins: [{ file: "src/navigation.tsx", line: 12 }],
source: "Home",
translation: "",
}
describe("MessagePanel", () => {
it("loads, filters, and saves messages through the repository", async () => {
const updateMessage = vi.fn(async (input) => ({
...message,
missing: false,
translation: input.translation,
}))
const repository: MessageRepository = {
getMessages: vi.fn(async () => [message]),
updateMessage,
}
render(<MessagePanel locale="zh-Hans" repository={repository} />)
expect(await screen.findByText("navigation.home")).toBeTruthy()
const textarea = screen.getByLabelText(
"navigation.home translation for zh-Hans"
)
fireEvent.change(textarea, { target: { value: "首页" } })
fireEvent.click(screen.getByRole("button", { name: "Save" }))
await waitFor(() => {
expect(updateMessage).toHaveBeenCalledWith({
catalog: "messages",
id: "navigation.home",
locale: "zh-Hans",
translation: "首页",
})
})
expect(await screen.findByText("Saved")).toBeTruthy()
fireEvent.change(screen.getByPlaceholderText("Search messages"), {
target: { value: "does not exist" },
})
expect(
screen.getByText("No messages match the current filters.")
).toBeTruthy()
})
})
+307
View File
@@ -0,0 +1,307 @@
import * as React from "react"
import { useOptionalMessageRepository } from "./message-repository"
import type { DevtoolMessage, MessageRepository } from "./types"
import "./message-panel.css"
type LoadState =
| { status: "loading" }
| { error: Error; status: "error" }
| { messages: readonly DevtoolMessage[]; status: "ready" }
export interface MessagePanelProps {
className?: string
locale: string
repository?: MessageRepository
}
function getMessageSearchText(message: DevtoolMessage) {
return [
message.id,
message.source,
message.translation,
...message.comments,
...message.origins.map((origin) => origin.file),
]
.join("\n")
.toLocaleLowerCase()
}
function MessageEditor({
locale,
message,
onSave,
}: {
locale: string
message: DevtoolMessage
onSave: (translation: string) => Promise<void>
}) {
const [translation, setTranslation] = React.useState(message.translation)
const [saveState, setSaveState] = React.useState<
"idle" | "saving" | "saved" | "error"
>("idle")
const submittedTranslationRef = React.useRef<string | undefined>(undefined)
const isDirty = translation !== message.translation
React.useEffect(() => {
setTranslation(message.translation)
if (submittedTranslationRef.current === message.translation) {
submittedTranslationRef.current = undefined
return
}
setSaveState("idle")
}, [message.translation])
const handleSave = async () => {
if (!isDirty || saveState === "saving") {
return
}
setSaveState("saving")
submittedTranslationRef.current = translation
try {
await onSave(translation)
setSaveState("saved")
} catch {
submittedTranslationRef.current = undefined
setSaveState("error")
}
}
return (
<article
className="i18n-message"
data-missing={message.missing || undefined}
data-obsolete={message.obsolete || undefined}
>
<header className="i18n-message__header">
<code className="i18n-message__id">{message.id}</code>
<span className="i18n-message__catalog">{message.catalog}</span>
</header>
<div className="i18n-message__source">
<span className="i18n-message__label">Source</span>
<p>{message.source || message.id}</p>
</div>
<label className="i18n-message__field">
<span className="i18n-message__label">{locale}</span>
<textarea
aria-label={`${message.id} translation for ${locale}`}
value={translation}
onChange={(event) => {
submittedTranslationRef.current = undefined
setTranslation(event.target.value)
setSaveState("idle")
}}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault()
void handleSave()
}
}}
/>
</label>
{(message.comments.length > 0 || message.origins.length > 0) && (
<footer className="i18n-message__meta">
{message.comments.map((comment) => (
<span key={comment}>{comment}</span>
))}
{message.origins.map((origin) => (
<span key={`${origin.file}:${origin.line ?? ""}`}>
{origin.file}
{origin.line ? `:${origin.line}` : ""}
</span>
))}
</footer>
)}
<div className="i18n-message__actions">
<span aria-live="polite" className="i18n-message__status">
{saveState === "saving" && "Saving…"}
{saveState === "saved" && "Saved"}
{saveState === "error" && "Could not save"}
</span>
<button
type="button"
disabled={!isDirty || saveState === "saving"}
onClick={() => void handleSave()}
>
Save
</button>
</div>
</article>
)
}
export function MessagePanel({
className,
locale,
repository: repositoryProp,
}: MessagePanelProps) {
const repositoryFromContext = useOptionalMessageRepository()
const repository = repositoryProp ?? repositoryFromContext
const [state, setState] = React.useState<LoadState>({ status: "loading" })
const [query, setQuery] = React.useState("")
const [missingOnly, setMissingOnly] = React.useState(false)
const loadRequestRef = React.useRef(0)
const loadMessages = React.useCallback(async () => {
const requestId = loadRequestRef.current + 1
loadRequestRef.current = requestId
if (!repository) {
setState({
error: new Error(
"MessagePanel requires a MessageRepositoryProvider or a repository prop."
),
status: "error",
})
return
}
setState({ status: "loading" })
try {
const messages = await repository.getMessages(locale)
if (requestId !== loadRequestRef.current) {
return
}
setState({ messages, status: "ready" })
} catch (error) {
if (requestId !== loadRequestRef.current) {
return
}
setState({
error:
error instanceof Error
? error
: new Error("Unable to load messages."),
status: "error",
})
}
}, [locale, repository])
React.useEffect(() => {
void loadMessages()
}, [loadMessages])
const visibleMessages = React.useMemo(() => {
if (state.status !== "ready") {
return []
}
const normalizedQuery = query.trim().toLocaleLowerCase()
return state.messages.filter((message) => {
if (missingOnly && !message.missing) {
return false
}
return (
!normalizedQuery ||
getMessageSearchText(message).includes(normalizedQuery)
)
})
}, [missingOnly, query, state])
const updateMessage = React.useCallback(
async (message: DevtoolMessage, translation: string) => {
if (!repository) {
throw new Error("No message repository is available.")
}
const updatedMessage = await repository.updateMessage({
catalog: message.catalog,
id: message.id,
locale,
translation,
})
setState((current) => {
if (current.status !== "ready") {
return current
}
return {
messages: current.messages.map((item) =>
item.catalog === updatedMessage.catalog &&
item.id === updatedMessage.id
? updatedMessage
: item
),
status: "ready",
}
})
},
[locale, repository]
)
const rootClassName = ["i18n-message-panel", className]
.filter(Boolean)
.join(" ")
return (
<section className={rootClassName}>
<header className="i18n-message-panel__toolbar">
<label className="i18n-message-panel__search">
<span className="i18n-visually-hidden">Search messages</span>
<input
type="search"
placeholder="Search messages"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</label>
<label className="i18n-message-panel__toggle">
<input
type="checkbox"
checked={missingOnly}
onChange={(event) => setMissingOnly(event.target.checked)}
/>
Missing only
</label>
{state.status === "ready" && (
<output className="i18n-message-panel__count">
{visibleMessages.length} / {state.messages.length}
</output>
)}
</header>
{state.status === "loading" ? (
<div className="i18n-message-panel__state" role="status">
Loading {locale}
</div>
) : state.status === "error" ? (
<div className="i18n-message-panel__state" role="alert">
<p>{state.error.message}</p>
<button type="button" onClick={() => void loadMessages()}>
Retry
</button>
</div>
) : visibleMessages.length === 0 ? (
<div className="i18n-message-panel__state">
No messages match the current filters.
</div>
) : (
<div className="i18n-message-panel__list">
{visibleMessages.map((message) => (
<MessageEditor
key={`${message.catalog}:${message.id}`}
locale={locale}
message={message}
onSave={(translation) => updateMessage(message, translation)}
/>
))}
</div>
)}
</section>
)
}
@@ -0,0 +1,39 @@
import * as React from "react"
import type { MessageRepository } from "./types"
const MessageRepositoryContext = React.createContext<MessageRepository | null>(
null
)
export interface MessageRepositoryProviderProps {
children: React.ReactNode
repository: MessageRepository
}
export function MessageRepositoryProvider({
children,
repository,
}: MessageRepositoryProviderProps) {
return (
<MessageRepositoryContext.Provider value={repository}>
{children}
</MessageRepositoryContext.Provider>
)
}
export function useMessageRepository() {
const repository = React.useContext(MessageRepositoryContext)
if (!repository) {
throw new Error(
"MessagePanel requires a MessageRepositoryProvider or a repository prop."
)
}
return repository
}
export function useOptionalMessageRepository() {
return React.useContext(MessageRepositoryContext)
}
+27
View File
@@ -0,0 +1,27 @@
export interface MessageOrigin {
file: string
line?: number
}
export interface DevtoolMessage {
catalog: string
comments: readonly string[]
id: string
missing: boolean
obsolete: boolean
origins: readonly MessageOrigin[]
source: string
translation: string
}
export interface UpdateMessageInput {
catalog: string
id: string
locale: string
translation: string
}
export interface MessageRepository {
getMessages(locale: string): Promise<readonly DevtoolMessage[]>
updateMessage(input: UpdateMessageInput): Promise<DevtoolMessage>
}
+1
View File
@@ -0,0 +1 @@
export * from "./runtime"
+13
View File
@@ -0,0 +1,13 @@
import * as React from "react"
import type { AllMessages } from "@lingui/core"
import type { LocaleDefinition } from "./types"
export interface I18nRuntimeContextValue {
catalogs: AllMessages
locale: string
locales: readonly LocaleDefinition[]
}
export const I18nRuntimeContext =
React.createContext<I18nRuntimeContextValue | null>(null)
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react"
import type { MessageDescriptor, MessageId, MessageOptions } from "@lingui/core"
import { useLingui } from "@lingui/react"
import { I18nRuntimeContext } from "./context"
import type { LocaleDefinition, TranslateFunction } from "./types"
export function useTranslate(): TranslateFunction {
return useLingui()._
}
export function useMessage(descriptor: MessageDescriptor): string
export function useMessage(
id: MessageId,
values?: Record<string, unknown>,
options?: MessageOptions
): string
export function useMessage(
descriptorOrId: MessageDescriptor | MessageId,
values?: Record<string, unknown>,
options?: MessageOptions
) {
const translate = useTranslate()
return React.useMemo(
() =>
typeof descriptorOrId === "string"
? translate(descriptorOrId, values, options)
: translate(descriptorOrId),
[descriptorOrId, options, translate, values]
)
}
export function useLocale(): string {
const runtime = React.useContext(I18nRuntimeContext)
const { i18n } = useLingui()
return runtime?.locale ?? i18n.locale
}
export function useLocales(): readonly LocaleDefinition[] {
const runtime = React.useContext(I18nRuntimeContext)
const { i18n } = useLingui()
return (
runtime?.locales ?? [
{
direction: "ltr",
label: i18n.locale,
locale: i18n.locale,
},
]
)
}
+122
View File
@@ -0,0 +1,122 @@
import * as React from "react"
import { setupI18n, type AllMessages, type I18n } from "@lingui/core"
import {
I18nProvider as LinguiI18nProvider,
type I18nProviderProps as LinguiI18nProviderProps,
} from "@lingui/react"
import { I18nRuntimeContext } from "./context"
import type { LocaleDefinition, LocaleDirection, LocaleInput } from "./types"
const RTL_LANGUAGES = new Set([
"ar",
"ckb",
"dv",
"fa",
"he",
"ku",
"ps",
"sd",
"ug",
"ur",
"yi",
])
function inferLocaleDirection(locale: string): LocaleDirection {
const language = locale.split("-")[0]?.toLowerCase()
return language && RTL_LANGUAGES.has(language) ? "rtl" : "ltr"
}
function normalizeLocales(
locale: string,
locales: readonly LocaleInput[] | undefined,
catalogs: AllMessages
): readonly LocaleDefinition[] {
const inputs =
locales && locales.length > 0
? locales
: Array.from(new Set([locale, ...Object.keys(catalogs)]))
const definitions = inputs.map<LocaleDefinition>((input) => {
const definition = typeof input === "string" ? { locale: input } : input
return {
...definition,
direction:
definition.direction ?? inferLocaleDirection(definition.locale),
label: definition.label ?? definition.locale,
}
})
if (!definitions.some((definition) => definition.locale === locale)) {
return [
...definitions,
{
direction: inferLocaleDirection(locale),
label: locale,
locale,
},
]
}
return definitions
}
export interface I18nProviderProps extends Pick<
LinguiI18nProviderProps,
"children" | "defaultComponent"
> {
catalogs?: AllMessages
i18n?: I18n
locale: string
locales?: readonly LocaleInput[]
missing?: ConstructorParameters<typeof I18n>[0]["missing"]
}
export function I18nProvider({
catalogs = {},
children,
defaultComponent,
i18n: providedI18n,
locale,
locales,
missing,
}: I18nProviderProps) {
const i18n = React.useMemo(() => {
const instance =
providedI18n ??
setupI18n({
locale,
messages: catalogs,
missing,
})
if (providedI18n) {
providedI18n.load(catalogs)
providedI18n.activate(locale)
}
return instance
}, [catalogs, locale, missing, providedI18n])
const normalizedLocales = React.useMemo(
() => normalizeLocales(locale, locales, catalogs),
[catalogs, locale, locales]
)
const contextValue = React.useMemo(
() => ({
catalogs,
locale,
locales: normalizedLocales,
}),
[catalogs, locale, normalizedLocales]
)
return (
<I18nRuntimeContext.Provider value={contextValue}>
<LinguiI18nProvider i18n={i18n} defaultComponent={defaultComponent}>
{children}
</LinguiI18nProvider>
</I18nRuntimeContext.Provider>
)
}
+15
View File
@@ -0,0 +1,15 @@
export { I18nProvider, type I18nProviderProps } from "./i18n-provider"
export { Translate, type TranslateProps } from "./translate"
export { useLocale, useLocales, useMessage, useTranslate } from "./hooks"
export type {
AllMessages,
I18n,
LocaleDefinition,
LocaleDirection,
LocaleInput,
MessageDescriptor,
MessageId,
MessageOptions,
Messages,
TranslateFunction,
} from "./types"
@@ -0,0 +1,77 @@
// @vitest-environment jsdom
import * as React from "react"
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { I18nProvider } from "./i18n-provider"
import { useLocale, useLocales, useMessage, useTranslate } from "./hooks"
import { Translate } from "./translate"
const catalogs = {
en: {
greeting: "Hello",
},
"zh-Hans": {
greeting: "你好",
},
}
function HookProbe() {
const locale = useLocale()
const locales = useLocales()
const message = useMessage("greeting")
const translate = useTranslate()
return (
<output>
{locale}|{locales.map((item) => item.locale).join(",")}|{message}|
{translate("greeting")}
</output>
)
}
describe("i18n runtime", () => {
it("provides locale information and reactive translation helpers", () => {
render(
<I18nProvider
catalogs={catalogs}
locale="zh-Hans"
locales={[
{ label: "English", locale: "en" },
{ label: "简体中文", locale: "zh-Hans" },
]}
>
<HookProbe />
</I18nProvider>
)
expect(screen.getByText("zh-Hans|en,zh-Hans|你好|你好")).toBeTruthy()
})
it("wraps Lingui Trans without adding a DOM element", () => {
const { container } = render(
<I18nProvider catalogs={catalogs} locale="en">
<p>
<Translate id="greeting" message="Fallback" />
</p>
</I18nProvider>
)
expect(container.innerHTML).toBe("<p>Hello</p>")
})
it("infers right-to-left locale direction", () => {
function DirectionProbe() {
return <span>{useLocales()[0]?.direction}</span>
}
render(
<I18nProvider locale="ar" locales={["ar"]}>
<DirectionProbe />
</I18nProvider>
)
expect(screen.getByText("rtl")).toBeTruthy()
})
})
+7
View File
@@ -0,0 +1,7 @@
import { Trans, type TransProps } from "@lingui/react"
export type TranslateProps = TransProps
export function Translate(props: TranslateProps) {
return <Trans {...props} />
}
+29
View File
@@ -0,0 +1,29 @@
import type {
AllMessages,
I18n,
MessageDescriptor,
MessageId,
MessageOptions,
Messages,
} from "@lingui/core"
export type {
AllMessages,
I18n,
MessageDescriptor,
MessageId,
MessageOptions,
Messages,
}
export type LocaleDirection = "ltr" | "rtl"
export interface LocaleDefinition {
direction?: LocaleDirection
label?: string
locale: string
}
export type LocaleInput = LocaleDefinition | string
export type TranslateFunction = I18n["_"]
+32
View File
@@ -0,0 +1,32 @@
import type { Plugin } from "vite"
import { configureI18nApi } from "./cli/api-plugin.ts"
import { resolveProjectFilename } from "./cli/project.ts"
export interface I18nPluginOptions {
project?: string
}
export function i18n(options: I18nPluginOptions = {}): Plugin {
let projectFilename: string | undefined
return {
name: "workspace-i18n",
apply: "serve",
async configResolved(config) {
projectFilename = await resolveProjectFilename(
options.project,
config.root
)
},
configureServer(server) {
if (!projectFilename) {
throw new Error(
"The @workspace/i18n Vite plugin could not resolve i18n.config.json."
)
}
configureI18nApi(server, projectFilename)
},
}
}