From 9e3f1d6b595cd868367459b093175629d045d92c Mon Sep 17 00:00:00 2001 From: Maofeng Date: Wed, 29 Jul 2026 21:23:00 +0800 Subject: [PATCH] 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 --- packages/i18n/README.md | 172 ++++++++++ packages/i18n/package.json | 44 +++ packages/i18n/src/cli/api-plugin.ts | 139 ++++++++ packages/i18n/src/cli/catalog-service.test.ts | 96 ++++++ packages/i18n/src/cli/catalog-service.ts | 154 +++++++++ packages/i18n/src/cli/dev-server.ts | 57 ++++ packages/i18n/src/cli/index.ts | 114 +++++++ packages/i18n/src/cli/lingui-command.ts | 46 +++ packages/i18n/src/cli/new-locale.test.ts | 16 + packages/i18n/src/cli/new-locale.ts | 65 ++++ packages/i18n/src/cli/project.test.ts | 46 +++ packages/i18n/src/cli/project.ts | 128 ++++++++ .../i18n/src/config/lingui-config.test.ts | 30 ++ packages/i18n/src/config/lingui-config.ts | 36 ++ packages/i18n/src/devtool/api-client.ts | 31 ++ packages/i18n/src/devtool/app/app.tsx | 58 ++++ packages/i18n/src/devtool/app/index.html | 17 + packages/i18n/src/devtool/app/main.tsx | 17 + packages/i18n/src/devtool/app/styles.css | 26 ++ .../src/devtool/http-message-repository.ts | 53 +++ packages/i18n/src/devtool/i18n-devtool.css | 236 ++++++++++++++ .../i18n/src/devtool/i18n-devtool.test.tsx | 42 +++ packages/i18n/src/devtool/i18n-devtool.tsx | 240 ++++++++++++++ packages/i18n/src/devtool/index.ts | 14 + packages/i18n/src/devtool/message-panel.css | 218 +++++++++++++ .../i18n/src/devtool/message-panel.test.tsx | 60 ++++ packages/i18n/src/devtool/message-panel.tsx | 307 ++++++++++++++++++ .../i18n/src/devtool/message-repository.tsx | 39 +++ packages/i18n/src/devtool/types.ts | 27 ++ packages/i18n/src/index.ts | 1 + packages/i18n/src/runtime/context.ts | 13 + packages/i18n/src/runtime/hooks.ts | 54 +++ packages/i18n/src/runtime/i18n-provider.tsx | 122 +++++++ packages/i18n/src/runtime/index.ts | 15 + packages/i18n/src/runtime/runtime.test.tsx | 77 +++++ packages/i18n/src/runtime/translate.tsx | 7 + packages/i18n/src/runtime/types.ts | 29 ++ packages/i18n/src/vite.ts | 32 ++ packages/i18n/tsconfig.json | 19 ++ 39 files changed, 2897 insertions(+) create mode 100644 packages/i18n/README.md create mode 100644 packages/i18n/package.json create mode 100644 packages/i18n/src/cli/api-plugin.ts create mode 100644 packages/i18n/src/cli/catalog-service.test.ts create mode 100644 packages/i18n/src/cli/catalog-service.ts create mode 100644 packages/i18n/src/cli/dev-server.ts create mode 100755 packages/i18n/src/cli/index.ts create mode 100644 packages/i18n/src/cli/lingui-command.ts create mode 100644 packages/i18n/src/cli/new-locale.test.ts create mode 100644 packages/i18n/src/cli/new-locale.ts create mode 100644 packages/i18n/src/cli/project.test.ts create mode 100644 packages/i18n/src/cli/project.ts create mode 100644 packages/i18n/src/config/lingui-config.test.ts create mode 100644 packages/i18n/src/config/lingui-config.ts create mode 100644 packages/i18n/src/devtool/api-client.ts create mode 100644 packages/i18n/src/devtool/app/app.tsx create mode 100644 packages/i18n/src/devtool/app/index.html create mode 100644 packages/i18n/src/devtool/app/main.tsx create mode 100644 packages/i18n/src/devtool/app/styles.css create mode 100644 packages/i18n/src/devtool/http-message-repository.ts create mode 100644 packages/i18n/src/devtool/i18n-devtool.css create mode 100644 packages/i18n/src/devtool/i18n-devtool.test.tsx create mode 100644 packages/i18n/src/devtool/i18n-devtool.tsx create mode 100644 packages/i18n/src/devtool/index.ts create mode 100644 packages/i18n/src/devtool/message-panel.css create mode 100644 packages/i18n/src/devtool/message-panel.test.tsx create mode 100644 packages/i18n/src/devtool/message-panel.tsx create mode 100644 packages/i18n/src/devtool/message-repository.tsx create mode 100644 packages/i18n/src/devtool/types.ts create mode 100644 packages/i18n/src/index.ts create mode 100644 packages/i18n/src/runtime/context.ts create mode 100644 packages/i18n/src/runtime/hooks.ts create mode 100644 packages/i18n/src/runtime/i18n-provider.tsx create mode 100644 packages/i18n/src/runtime/index.ts create mode 100644 packages/i18n/src/runtime/runtime.test.tsx create mode 100644 packages/i18n/src/runtime/translate.tsx create mode 100644 packages/i18n/src/runtime/types.ts create mode 100644 packages/i18n/src/vite.ts create mode 100644 packages/i18n/tsconfig.json diff --git a/packages/i18n/README.md b/packages/i18n/README.md new file mode 100644 index 0000000..649373b --- /dev/null +++ b/packages/i18n/README.md @@ -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 ( + + {children} + {import.meta.env.DEV && } + + ) +} + +function Greeting() { + const locale = useLocale() + const locales = useLocales() + const title = useMessage("dashboard.title") + const translate = useTranslate() + + return ( + <> + + {title} + + ) +} +``` + +`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 `` 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 + + + {import.meta.env.DEV && } + +``` + +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 ` 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 ` 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. diff --git a/packages/i18n/package.json b/packages/i18n/package.json new file mode 100644 index 0000000..5092768 --- /dev/null +++ b/packages/i18n/package.json @@ -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" + } +} diff --git a/packages/i18n/src/cli/api-plugin.ts b/packages/i18n/src/cli/api-plugin.ts new file mode 100644 index 0000000..afa4dbf --- /dev/null +++ b/packages/i18n/src/cli/api-plugin.ts @@ -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 + + 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.", + }) + } + }) +} diff --git a/packages/i18n/src/cli/catalog-service.test.ts b/packages/i18n/src/cli/catalog-service.test.ts new file mode 100644 index 0000000..65ddc77 --- /dev/null +++ b/packages/i18n/src/cli/catalog-service.test.ts @@ -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: "/locales/{locale}/messages",', + ' include: ["/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 "数据看板"' + ) + }) +}) diff --git a/packages/i18n/src/cli/catalog-service.ts b/packages/i18n/src/cli/catalog-service.ts new file mode 100644 index 0000000..7135920 --- /dev/null +++ b/packages/i18n/src/cli/catalog-service.ts @@ -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>[number] + key: string +} + +async function getCatalogEntries(config: ReturnType) { + const catalogs = await getCatalogs(config) + + return catalogs.map((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 + readonly catalogs: Promise + + 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}`) + } + } +} diff --git a/packages/i18n/src/cli/dev-server.ts b/packages/i18n/src/cli/dev-server.ts new file mode 100644 index 0000000..a3d1d74 --- /dev/null +++ b/packages/i18n/src/cli/dev-server.ts @@ -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 +} diff --git a/packages/i18n/src/cli/index.ts b/packages/i18n/src/cli/index.ts new file mode 100755 index 0000000..b971aec --- /dev/null +++ b/packages/i18n/src/cli/index.ts @@ -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 ", "Add a BCP-47 locale and create its Lingui catalog") + .option("--project ", "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 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 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 to i18n.config.json") + .option("--host ", "Host to listen on", { + default: "localhost", + }) + .option("--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() diff --git a/packages/i18n/src/cli/lingui-command.ts b/packages/i18n/src/cli/lingui-command.ts new file mode 100644 index 0000000..6a098ed --- /dev/null +++ b/packages/i18n/src/cli/lingui-command.ts @@ -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 { + 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"}.` + ) + ) + }) + }) +} diff --git a/packages/i18n/src/cli/new-locale.test.ts b/packages/i18n/src/cli/new-locale.test.ts new file mode 100644 index 0000000..a519e32 --- /dev/null +++ b/packages/i18n/src/cli/new-locale.test.ts @@ -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" + ) + }) +}) diff --git a/packages/i18n/src/cli/new-locale.ts b/packages/i18n/src/cli/new-locale.ts new file mode 100644 index 0000000..1555472 --- /dev/null +++ b/packages/i18n/src/cli/new-locale.ts @@ -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 + } +} diff --git a/packages/i18n/src/cli/project.test.ts b/packages/i18n/src/cli/project.test.ts new file mode 100644 index 0000000..c7539d1 --- /dev/null +++ b/packages/i18n/src/cli/project.test.ts @@ -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") + }) +}) diff --git a/packages/i18n/src/cli/project.ts b/packages/i18n/src/cli/project.ts new file mode 100644 index 0000000..03cc94c --- /dev/null +++ b/packages/i18n/src/cli/project.ts @@ -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 + + 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 +} diff --git a/packages/i18n/src/config/lingui-config.test.ts b/packages/i18n/src/config/lingui-config.test.ts new file mode 100644 index 0000000..0d62b7b --- /dev/null +++ b/packages/i18n/src/config/lingui-config.test.ts @@ -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: ["/**/*.test.ts"], + include: ["/src"], + path: "/locales/{locale}/messages", + }, + ], + locales: ["en", "zh-Hans"], + runtimeConfigModule: { + Trans: ["@workspace/i18n", "Translate"], + }, + sourceLocale: "en", + }) + }) +}) diff --git a/packages/i18n/src/config/lingui-config.ts b/packages/i18n/src/config/lingui-config.ts new file mode 100644 index 0000000..60902f4 --- /dev/null +++ b/packages/i18n/src/config/lingui-config.ts @@ -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("")) { + return pattern + } + + return `/${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, + } +} diff --git a/packages/i18n/src/devtool/api-client.ts b/packages/i18n/src/devtool/api-client.ts new file mode 100644 index 0000000..e278116 --- /dev/null +++ b/packages/i18n/src/devtool/api-client.ts @@ -0,0 +1,31 @@ +export type DevtoolAction = "compile" | "extract" + +interface ApiError { + error?: string +} + +export async function requestDevtoolJson( + url: string, + init?: RequestInit +): Promise { + 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" } + ) +} diff --git a/packages/i18n/src/devtool/app/app.tsx b/packages/i18n/src/devtool/app/app.tsx new file mode 100644 index 0000000..f94e327 --- /dev/null +++ b/packages/i18n/src/devtool/app/app.tsx @@ -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({ status: "loading" }) + + React.useEffect(() => { + requestDevtoolJson("/__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 ( +
+ {state.status === "error" + ? state.error.message + : "Loading I18n Devtool…"} +
+ ) + } + + const locale = state.project.locales[0] + + if (!locale) { + return ( +
+ Add a locale with workspace-i18n new <locale>. +
+ ) + } + + return ( + + + + ) +} diff --git a/packages/i18n/src/devtool/app/index.html b/packages/i18n/src/devtool/app/index.html new file mode 100644 index 0000000..cf6746b --- /dev/null +++ b/packages/i18n/src/devtool/app/index.html @@ -0,0 +1,17 @@ + + + + + + + Message Studio + + +
+ + + + diff --git a/packages/i18n/src/devtool/app/main.tsx b/packages/i18n/src/devtool/app/main.tsx new file mode 100644 index 0000000..a408b21 --- /dev/null +++ b/packages/i18n/src/devtool/app/main.tsx @@ -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( + + + +) diff --git a/packages/i18n/src/devtool/app/styles.css b/packages/i18n/src/devtool/app/styles.css new file mode 100644 index 0000000..152f4de --- /dev/null +++ b/packages/i18n/src/devtool/app/styles.css @@ -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; +} diff --git a/packages/i18n/src/devtool/http-message-repository.ts b/packages/i18n/src/devtool/http-message-repository.ts new file mode 100644 index 0000000..5c4b1a6 --- /dev/null +++ b/packages/i18n/src/devtool/http-message-repository.ts @@ -0,0 +1,53 @@ +import type { + DevtoolMessage, + MessageRepository, + UpdateMessageInput, +} from "./types" + +interface ApiError { + error?: string +} + +async function readResponse(response: Response): Promise { + if (response.ok) { + return response.json() as Promise + } + + 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(response) + }, + async updateMessage(input: UpdateMessageInput) { + const response = await fetch(`${normalizedBaseUrl}/messages`, { + body: JSON.stringify(input), + headers: { + "content-type": "application/json", + }, + method: "PUT", + }) + + return readResponse(response) + }, + } +} diff --git a/packages/i18n/src/devtool/i18n-devtool.css b/packages/i18n/src/devtool/i18n-devtool.css new file mode 100644 index 0000000..4991a37 --- /dev/null +++ b/packages/i18n/src/devtool/i18n-devtool.css @@ -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; + } +} diff --git a/packages/i18n/src/devtool/i18n-devtool.test.tsx b/packages/i18n/src/devtool/i18n-devtool.test.tsx new file mode 100644 index 0000000..5456fbb --- /dev/null +++ b/packages/i18n/src/devtool/i18n-devtool.test.tsx @@ -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( + + + + ) + + const devtool = await screen + .findByText("i18n") + .then((element) => + element.closest('[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) + }) + }) +}) diff --git a/packages/i18n/src/devtool/i18n-devtool.tsx b/packages/i18n/src/devtool/i18n-devtool.tsx new file mode 100644 index 0000000..864d1c8 --- /dev/null +++ b/packages/i18n/src/devtool/i18n-devtool.tsx @@ -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( + 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( + "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( +
+ {mode === "floating" && !isOpen && ( + + )} + + {isOpen && ( + + )} +
, + portalTarget + ) +} diff --git a/packages/i18n/src/devtool/index.ts b/packages/i18n/src/devtool/index.ts new file mode 100644 index 0000000..ad9b7ef --- /dev/null +++ b/packages/i18n/src/devtool/index.ts @@ -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" diff --git a/packages/i18n/src/devtool/message-panel.css b/packages/i18n/src/devtool/message-panel.css new file mode 100644 index 0000000..4db30af --- /dev/null +++ b/packages/i18n/src/devtool/message-panel.css @@ -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%; + } +} diff --git a/packages/i18n/src/devtool/message-panel.test.tsx b/packages/i18n/src/devtool/message-panel.test.tsx new file mode 100644 index 0000000..06d816c --- /dev/null +++ b/packages/i18n/src/devtool/message-panel.test.tsx @@ -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() + + 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() + }) +}) diff --git a/packages/i18n/src/devtool/message-panel.tsx b/packages/i18n/src/devtool/message-panel.tsx new file mode 100644 index 0000000..b5afc2d --- /dev/null +++ b/packages/i18n/src/devtool/message-panel.tsx @@ -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 +}) { + const [translation, setTranslation] = React.useState(message.translation) + const [saveState, setSaveState] = React.useState< + "idle" | "saving" | "saved" | "error" + >("idle") + const submittedTranslationRef = React.useRef(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 ( +
+
+ {message.id} + {message.catalog} +
+ +
+ Source +

{message.source || message.id}

+
+ +