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