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:
@@ -0,0 +1,172 @@
|
||||
# @workspace/i18n
|
||||
|
||||
Reusable Lingui runtime, Devtool, Vite plugin, and CLI. This package does not own
|
||||
application locales or catalogs: every consuming application keeps its own
|
||||
`i18n.config.json`, `lingui.config.ts`, and generated message files.
|
||||
|
||||
## Consumer setup
|
||||
|
||||
Install the package in an application and add scripts that invoke its CLI:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@lingui/core": "^6",
|
||||
"@workspace/i18n": "workspace:*"
|
||||
},
|
||||
"scripts": {
|
||||
"i18n": "workspace-i18n",
|
||||
"i18n:compile": "workspace-i18n compile",
|
||||
"i18n:extract": "workspace-i18n extract",
|
||||
"i18n:ui": "workspace-i18n ui"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create `i18n.config.json` in that application:
|
||||
|
||||
```json
|
||||
{
|
||||
"sourceLocale": "en",
|
||||
"locales": ["en", "zh-Hans"],
|
||||
"catalogPath": "src/locales/{locale}/messages",
|
||||
"include": ["src"],
|
||||
"exclude": ["**/*.test.{ts,tsx}"]
|
||||
}
|
||||
```
|
||||
|
||||
Then expose it as a Lingui configuration:
|
||||
|
||||
```ts
|
||||
import project from "./i18n.config.json" with { type: "json" }
|
||||
import { createLinguiConfig } from "@workspace/i18n/lingui-config"
|
||||
|
||||
export default createLinguiConfig(project)
|
||||
```
|
||||
|
||||
Mount the API plugin in the consuming application's Vite configuration:
|
||||
|
||||
```ts
|
||||
import { i18n } from "@workspace/i18n/vite"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [i18n()],
|
||||
})
|
||||
```
|
||||
|
||||
## Runtime
|
||||
|
||||
Compiled Lingui catalogs are passed to the provider synchronously, so the
|
||||
first client render and SSR render use the same locale.
|
||||
|
||||
```tsx
|
||||
import { messages as en } from "@/locales/en/messages"
|
||||
import { messages as zhHans } from "@/locales/zh-Hans/messages"
|
||||
import {
|
||||
I18nProvider,
|
||||
Translate,
|
||||
useLocale,
|
||||
useLocales,
|
||||
useMessage,
|
||||
useTranslate,
|
||||
} from "@workspace/i18n"
|
||||
import { I18nDevtool } from "@workspace/i18n/devtool"
|
||||
|
||||
function Root({ children, locale }: React.PropsWithChildren<{ locale: string }>) {
|
||||
return (
|
||||
<I18nProvider
|
||||
locale={locale}
|
||||
locales={[
|
||||
{ locale: "en", label: "English" },
|
||||
{ locale: "zh-Hans", label: "简体中文" },
|
||||
]}
|
||||
catalogs={{ en, "zh-Hans": zhHans }}
|
||||
>
|
||||
{children}
|
||||
{import.meta.env.DEV && <I18nDevtool dark={"ui\\:dark"} />}
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function Greeting() {
|
||||
const locale = useLocale()
|
||||
const locales = useLocales()
|
||||
const title = useMessage("dashboard.title")
|
||||
const translate = useTranslate()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Translate
|
||||
id="welcome"
|
||||
message="Welcome, {name}"
|
||||
values={{ name: "Ada" }}
|
||||
/>
|
||||
<span>{title}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`Translate` is a thin wrapper around Lingui's runtime `Trans` component. It
|
||||
does not add a DOM wrapper and retains `values`, `components`, `formats`,
|
||||
`component`, and `render`.
|
||||
|
||||
The generated Lingui configuration points `runtimeConfigModule.Trans` at
|
||||
`Translate`, so explicit `<Translate id="…" message="…" />` usages are found
|
||||
by `lingui extract`.
|
||||
|
||||
## Devtool
|
||||
|
||||
`I18nDevtool` must be mounted inside `I18nProvider`. It reads the active locale
|
||||
and available locales from the provider, while the Vite `i18n()` plugin serves
|
||||
catalog reads, updates, extraction, and compilation.
|
||||
|
||||
```tsx
|
||||
<I18nProvider locale="zh-Hans" locales={["en", "zh-Hans"]}>
|
||||
<App />
|
||||
{import.meta.env.DEV && <I18nDevtool dark={"ui\\:dark"} />}
|
||||
</I18nProvider>
|
||||
```
|
||||
|
||||
The `dark` value can be a class name such as `ui\:dark`, or a CSS selector
|
||||
such as `[data-theme="dark"]`. The Devtool observes document theme changes and
|
||||
renders through a portal, so application overflow and stacking contexts do not
|
||||
clip it.
|
||||
|
||||
Lower-level `MessagePanel`, `MessageRepositoryProvider`, and repository types
|
||||
remain available from `@workspace/i18n/devtool`.
|
||||
|
||||
## CLI
|
||||
|
||||
Run inside the consuming application so the CLI discovers that application's
|
||||
`i18n.config.json`:
|
||||
|
||||
```sh
|
||||
bun run i18n new ja
|
||||
bun run i18n extract
|
||||
bun run i18n compile
|
||||
bun run i18n ui
|
||||
```
|
||||
|
||||
Commands:
|
||||
|
||||
- `new <locale>` validates and canonicalizes a BCP-47 locale, updates
|
||||
`i18n.config.json`, and asks Lingui to extract its catalog.
|
||||
- `extract` extracts application messages into its catalogs.
|
||||
- `compile` compiles application catalogs to TypeScript modules.
|
||||
- `ui` starts Message Studio. It reads and writes PO catalogs through Lingui's
|
||||
catalog API, and provides Extract and Compile actions.
|
||||
|
||||
All commands accept `--project <path>` when invoked outside the application.
|
||||
|
||||
## Catalog workflow
|
||||
|
||||
```sh
|
||||
cd apps/web
|
||||
bun run i18n:extract
|
||||
bun run i18n:compile
|
||||
```
|
||||
|
||||
The application's `i18n.config.json` is the machine-editable source of truth.
|
||||
Its `lingui.config.ts` maps that manifest to Lingui's configuration format.
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@workspace/i18n",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"bin": {
|
||||
"workspace-i18n": "./src/cli/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "prettier --write \"**/*.{css,json,ts,tsx}\"",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./devtool": "./src/devtool/index.ts",
|
||||
"./lingui-config": "./src/config/lingui-config.ts",
|
||||
"./vite": "./src/vite.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lingui/cli": "^6",
|
||||
"@lingui/conf": "^6",
|
||||
"@lingui/core": "^6",
|
||||
"@lingui/react": "^6",
|
||||
"@vitejs/plugin-react": "^6",
|
||||
"cac": "^7.0.0",
|
||||
"open": "^11.0.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"vite": "^8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16",
|
||||
"@types/node": "^24",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jsdom": "^30",
|
||||
"typescript": "~6",
|
||||
"vitest": "^4"
|
||||
}
|
||||
}
|
||||
@@ -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 "数据看板"'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Executable
+114
@@ -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()
|
||||
@@ -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"}.`
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
)
|
||||
}
|
||||
@@ -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 <locale></code>.
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<I18nProvider locale={locale} locales={state.project.locales}>
|
||||
<I18nDevtool defaultOpen mode="standalone" />
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./runtime"
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Trans, type TransProps } from "@lingui/react"
|
||||
|
||||
export type TranslateProps = TransProps
|
||||
|
||||
export function Translate(props: TranslateProps) {
|
||||
return <Trans {...props} />
|
||||
}
|
||||
@@ -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["_"]
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["node", "vite/client"],
|
||||
"paths": {
|
||||
"@workspace/i18n/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "lingui.config.ts"],
|
||||
"exclude": ["node_modules", "dist", "locales"]
|
||||
}
|
||||
Reference in New Issue
Block a user