From 0768e8fa292422fb0a8cd9a473ac7c08da711f0e Mon Sep 17 00:00:00 2001 From: Maofeng Date: Thu, 30 Jul 2026 14:47:42 +0800 Subject: [PATCH] feat(i18n): expand catalog runtime and developer tooling - add Intl-backed locale definitions, formatters, catalog loaders, and runtime hooks\n- compile hashed production catalogs from package-scoped catalog sources\n- drive Lingui extraction and compilation directly from project JSON config\n- redesign the devtool with localized controls, draggable persistence, settings, navigation, copy actions, ANSI output, and fullscreen support\n- extend repository APIs, CLI coverage, documentation, and runtime tests --- .gitignore | 3 + packages/i18n/README.md | 126 ++- packages/i18n/package.json | 1 + .../i18n/src/build/catalog-sources.test.ts | 90 ++ packages/i18n/src/build/catalog-sources.ts | 125 +++ .../src/build/production-catalogs.test.ts | 42 + .../i18n/src/build/production-catalogs.ts | 79 ++ packages/i18n/src/cli/api-plugin.ts | 27 +- packages/i18n/src/cli/catalog-service.test.ts | 102 +- packages/i18n/src/cli/catalog-service.ts | 208 +++- packages/i18n/src/cli/index.ts | 18 +- packages/i18n/src/cli/lingui-command.test.ts | 69 ++ packages/i18n/src/cli/lingui-command.ts | 177 +++- packages/i18n/src/cli/new-locale.ts | 23 +- packages/i18n/src/cli/project.test.ts | 33 +- packages/i18n/src/cli/project.ts | 22 +- .../i18n/src/config/lingui-config.test.ts | 28 +- packages/i18n/src/config/lingui-config.ts | 17 +- packages/i18n/src/devtool/ansi-text.test.tsx | 29 + packages/i18n/src/devtool/ansi-text.tsx | 39 + packages/i18n/src/devtool/api-client.ts | 11 + packages/i18n/src/devtool/app/app.tsx | 15 +- .../src/devtool/devtool-action-output.tsx | 65 ++ .../src/devtool/devtool-locale-select.tsx | 50 + .../src/devtool/devtool-localization.test.ts | 21 + .../i18n/src/devtool/devtool-localization.tsx | 933 ++++++++++++++++++ .../i18n/src/devtool/devtool-preferences.ts | 124 +++ packages/i18n/src/devtool/devtool-select.tsx | 38 + .../i18n/src/devtool/devtool-settings.tsx | 159 +++ .../src/devtool/devtool-theme-switcher.tsx | 58 ++ .../src/devtool/http-message-repository.ts | 6 + .../i18n/src/devtool/i18n-devtool-trigger.tsx | 58 ++ packages/i18n/src/devtool/i18n-devtool.css | 718 +++++++++++++- .../i18n/src/devtool/i18n-devtool.test.tsx | 589 ++++++++++- packages/i18n/src/devtool/i18n-devtool.tsx | 398 ++++++-- packages/i18n/src/devtool/index.ts | 14 + .../i18n/src/devtool/message-copy-menu.tsx | 285 ++++++ .../i18n/src/devtool/message-navigation.tsx | 356 +++++++ packages/i18n/src/devtool/message-panel.css | 789 ++++++++++++++- .../i18n/src/devtool/message-panel.test.tsx | 337 ++++++- packages/i18n/src/devtool/message-panel.tsx | 515 ++++++++-- packages/i18n/src/devtool/types.ts | 9 + .../i18n/src/devtool/use-floating-trigger.ts | 451 +++++++++ packages/i18n/src/runtime/catalog-loader.ts | 12 + packages/i18n/src/runtime/catalogs.ts | 28 + packages/i18n/src/runtime/context.ts | 8 +- packages/i18n/src/runtime/hooks.ts | 75 +- packages/i18n/src/runtime/i18n-provider.tsx | 128 ++- packages/i18n/src/runtime/index.ts | 19 +- packages/i18n/src/runtime/intl-formatters.ts | 58 ++ packages/i18n/src/runtime/runtime.test.tsx | 110 ++- packages/i18n/src/runtime/translate.tsx | 10 +- packages/i18n/src/runtime/types.ts | 80 +- packages/i18n/src/vite.ts | 261 ++++- packages/i18n/tsconfig.json | 2 +- 55 files changed, 7553 insertions(+), 495 deletions(-) create mode 100644 packages/i18n/src/build/catalog-sources.test.ts create mode 100644 packages/i18n/src/build/catalog-sources.ts create mode 100644 packages/i18n/src/build/production-catalogs.test.ts create mode 100644 packages/i18n/src/build/production-catalogs.ts create mode 100644 packages/i18n/src/cli/lingui-command.test.ts create mode 100644 packages/i18n/src/devtool/ansi-text.test.tsx create mode 100644 packages/i18n/src/devtool/ansi-text.tsx create mode 100644 packages/i18n/src/devtool/devtool-action-output.tsx create mode 100644 packages/i18n/src/devtool/devtool-locale-select.tsx create mode 100644 packages/i18n/src/devtool/devtool-localization.test.ts create mode 100644 packages/i18n/src/devtool/devtool-localization.tsx create mode 100644 packages/i18n/src/devtool/devtool-preferences.ts create mode 100644 packages/i18n/src/devtool/devtool-select.tsx create mode 100644 packages/i18n/src/devtool/devtool-settings.tsx create mode 100644 packages/i18n/src/devtool/devtool-theme-switcher.tsx create mode 100644 packages/i18n/src/devtool/i18n-devtool-trigger.tsx create mode 100644 packages/i18n/src/devtool/message-copy-menu.tsx create mode 100644 packages/i18n/src/devtool/message-navigation.tsx create mode 100644 packages/i18n/src/devtool/use-floating-trigger.ts create mode 100644 packages/i18n/src/runtime/catalog-loader.ts create mode 100644 packages/i18n/src/runtime/catalogs.ts create mode 100644 packages/i18n/src/runtime/intl-formatters.ts diff --git a/.gitignore b/.gitignore index 4f941f9..9b99165 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ dist-ssr # turbo .turbo +# generated i18n build schema +.i18n + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/packages/i18n/README.md b/packages/i18n/README.md index 649373b..cb54a11 100644 --- a/packages/i18n/README.md +++ b/packages/i18n/README.md @@ -1,8 +1,9 @@ # @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. +Reusable Lingui runtime, Devtool, Vite plugin, and CLI. Every consuming +application owns its `i18n.config.json` and application catalogs. Reusable +packages may additionally ship built-in catalogs, which the application can +compose and override. ## Consumer setup @@ -30,19 +31,29 @@ Create `i18n.config.json` in that application: "sourceLocale": "en", "locales": ["en", "zh-Hans"], "catalogPath": "src/locales/{locale}/messages", - "include": ["src"], + "catalogSources": [ + "@workspace/ui/locales/{locale}", + "@workspace/blocks/navigation/locales/{locale}" + ], + "include": [ + "src", + "../../packages/ui/src", + "../../packages/blocks/src/blocks/navigation" + ], "exclude": ["**/*.test.{ts,tsx}"] } ``` -Then expose it as a Lingui configuration: +`i18n.config.json` is the application's only persistent i18n configuration. +The package converts it to Lingui's normalized configuration in memory. CLI +commands create a temporary Lingui module only for the lifetime of the child +process, so consuming applications do not need a `lingui.config.ts`. -```ts -import project from "./i18n.config.json" with { type: "json" } -import { createLinguiConfig } from "@workspace/i18n/lingui-config" - -export default createLinguiConfig(project) -``` +`catalogSources` lists reusable catalogs in merge order. The application +catalog from `catalogPath` is always merged last, so translations customized +in the application override package defaults. Keep dependency source folders +in `include` when the application should be able to edit those overrides in +Message Studio. Mount the API plugin in the consuming application's Vite configuration: @@ -57,12 +68,14 @@ export default defineConfig({ ## Runtime -Compiled Lingui catalogs are passed to the provider synchronously, so the -first client render and SSR render use the same locale. +The Vite plugin exposes the merged catalog through +`@workspace/i18n/catalogs`. Development loads source modules directly. A +production build emits one content-hashed JSON asset per locale and embeds the +matching compact catalog in the SSR bundle. ```tsx -import { messages as en } from "@/locales/en/messages" -import { messages as zhHans } from "@/locales/zh-Hans/messages" +import { use } from "react" +import { loadMessageCatalog } from "@workspace/i18n/catalogs" import { I18nProvider, Translate, @@ -73,7 +86,12 @@ import { } from "@workspace/i18n" import { I18nDevtool } from "@workspace/i18n/devtool" -function Root({ children, locale }: React.PropsWithChildren<{ locale: string }>) { +function Root({ + children, + locale, +}: React.PropsWithChildren<{ locale: string }>) { + const messages = use(loadMessageCatalog(locale)) + return ( ) { locale: "en", label: "English" }, { locale: "zh-Hans", label: "简体中文" }, ]} - catalogs={{ en, "zh-Hans": zhHans }} + catalogs={{ [locale]: messages }} > {children} {import.meta.env.DEV && } @@ -108,13 +126,54 @@ function Greeting() { } ``` +`@workspace/ui` and every `@workspace/blocks` block provide `de`, `en`, `es`, +`fr`, `ja`, `ko`, `zh-Hans`, and `zh-Hant` as independent locale subpaths. +Import only the blocks and languages enabled by the application: + +```ts +import { + calendarLocale, + messages as uiJapanese, +} from "@workspace/ui/locales/ja" +import { messages as navigationJapanese } from "@workspace/blocks/navigation/locales/ja" +``` + +The UI root `locales` entry and each block-specific `locales` entry export +metadata and types only. They do not statically import catalogs. For example, +using `@workspace/blocks/navigation` and +`@workspace/blocks/navigation/locales/ja` does not import appearance, chats, +layout, notifications, or their translations. The UI locale entry also +exposes its matching `react-day-picker` `calendarLocale`. + +Catalog sources are merged from left to right, so application catalogs should +be last: they can override a package translation without forking that package. +An application therefore only needs to translate its own messages unless it +intentionally customizes package copy. + +## Production catalogs + +During `vite build`, the plugin: + +1. merges configured package catalogs and the application catalog per locale; +2. creates one deterministic shared schema from semantic message IDs; +3. replaces those IDs in production JavaScript with 10-character SHA-256 + Base64URL keys; +4. emits one cacheable `assets/i18n/-.json` file for each + locale. + +The private semantic-to-compact schema is written to `.i18n/schema.json` for +diagnostics and is ignored by Git. It is not shipped to the browser. Because +every locale uses the same compact key, changing languages only loads the +selected locale asset; package catalogs are not duplicated in the main +application bundle. + `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`. +The generated in-memory Lingui configuration points +`runtimeConfigModule.Trans` at `Translate`, so explicit +`` usages are found by `lingui extract`. ## Devtool @@ -132,11 +191,31 @@ catalog reads, updates, extraction, and compilation. 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. +clip it. Its settings panel can override the theme with `auto`, `light`, or +`dark`, and place the floating panel at the left, center, or right of the +viewport. These preferences and the draggable trigger position persist across +page reloads. + +The Devtool control-panel language is independent from the message locale. +Pass a BCP 47 language tag with `locale` to select it explicitly: + +```tsx + +``` + +When `locale` is omitted, the Devtool selects the first supported entry from +`navigator.languages` and falls back to English. Built-in control-panel +locales are English, German, Spanish, French, Japanese, Korean, Simplified +Chinese, and Traditional Chinese. Lower-level `MessagePanel`, `MessageRepositoryProvider`, and repository types remain available from `@workspace/i18n/devtool`. +The source locale remains available in the target-locale selector. Application +messages are read-only there because their source text belongs in source code; +messages extracted from dependency packages remain editable, and their source +locale translations are stored as application-level overrides. + ## CLI Run inside the consuming application so the CLI discovers that application's @@ -168,5 +247,6 @@ 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. +The application's `i18n.config.json` is the single machine-editable source of +truth. Runtime catalog access, Devtool actions, extraction, and compilation all +derive their Lingui configuration from it. diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 0bebd06..ecefbbd 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -16,6 +16,7 @@ }, "exports": { ".": "./src/index.ts", + "./catalogs": "./src/runtime/catalog-loader.ts", "./devtool": "./src/devtool/index.ts", "./lingui-config": "./src/config/lingui-config.ts", "./vite": "./src/vite.ts" diff --git a/packages/i18n/src/build/catalog-sources.test.ts b/packages/i18n/src/build/catalog-sources.test.ts new file mode 100644 index 0000000..049af14 --- /dev/null +++ b/packages/i18n/src/build/catalog-sources.test.ts @@ -0,0 +1,90 @@ +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 type { I18nProjectConfig } from "../config/lingui-config" +import { + loadProjectCatalogs, + resolveCatalogSourceFilenames, +} from "./catalog-sources" + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ) +}) + +async function createProject() { + const projectRoot = await mkdtemp(join(tmpdir(), "workspace-i18n-build-")) + const project: I18nProjectConfig = { + catalogPath: "app/{locale}/messages", + catalogSources: ["./dependency/{locale}.js"], + include: ["src"], + locales: ["en", "zh-Hans"], + sourceLocale: "en", + } + + temporaryDirectories.push(projectRoot) + + for (const locale of project.locales) { + const dependencyDirectory = join(projectRoot, "dependency") + const applicationDirectory = join(projectRoot, "app", locale) + + await mkdir(dependencyDirectory, { recursive: true }) + await mkdir(applicationDirectory, { recursive: true }) + await writeFile( + join(dependencyDirectory, `${locale}.js`), + `export const messages = ${JSON.stringify({ + dependency: `${locale}: dependency`, + overridden: `${locale}: dependency`, + })}\n` + ) + await writeFile( + join(applicationDirectory, "messages.js"), + `export const messages = ${JSON.stringify({ + application: `${locale}: application`, + overridden: `${locale}: application`, + })}\n` + ) + } + + return { project, projectRoot } +} + +describe("production catalog sources", () => { + it("resolves package sources before the application catalog", async () => { + const { project, projectRoot } = await createProject() + const filenames = await resolveCatalogSourceFilenames( + project, + projectRoot, + "en" + ) + + expect(filenames).toEqual([ + join(projectRoot, "dependency", "en.js"), + join(projectRoot, "app", "en", "messages.js"), + ]) + }) + + it("merges every locale and lets the application override dependencies", async () => { + const { project, projectRoot } = await createProject() + const { catalogs } = await loadProjectCatalogs(project, projectRoot) + + expect(catalogs.en).toEqual({ + application: "en: application", + dependency: "en: dependency", + overridden: "en: application", + }) + expect(catalogs["zh-Hans"]).toEqual({ + application: "zh-Hans: application", + dependency: "zh-Hans: dependency", + overridden: "zh-Hans: application", + }) + }) +}) diff --git a/packages/i18n/src/build/catalog-sources.ts b/packages/i18n/src/build/catalog-sources.ts new file mode 100644 index 0000000..af5d38c --- /dev/null +++ b/packages/i18n/src/build/catalog-sources.ts @@ -0,0 +1,125 @@ +import { access, stat } from "node:fs/promises" +import { createRequire } from "node:module" +import { isAbsolute, resolve } from "node:path" +import { pathToFileURL } from "node:url" + +import type { I18nProjectConfig } from "../config/lingui-config.ts" +import { mergeMessageCatalogs } from "../runtime/catalogs.ts" +import type { MessageCatalog, MessageCatalogs } from "../runtime/types.ts" + +function resolveLocaleTemplate(template: string, locale: string) { + return template.replaceAll("{locale}", locale) +} + +async function fileExists(filename: string) { + try { + await access(filename) + return true + } catch { + return false + } +} + +export async function resolveCompiledCatalogFilename( + project: I18nProjectConfig, + projectRoot: string, + locale: string +) { + const pathname = resolveLocaleTemplate(project.catalogPath, locale) + const baseFilename = isAbsolute(pathname) + ? pathname + : resolve(projectRoot, pathname) + const candidates = [baseFilename, `${baseFilename}.ts`, `${baseFilename}.js`] + + for (const candidate of candidates) { + if (await fileExists(candidate)) { + return candidate + } + } + + throw new Error( + `Compiled i18n catalog not found for ${locale}: ${baseFilename}.{ts,js}. Run the i18n compile command before building.` + ) +} + +export function resolveCatalogSourceFilename( + source: string, + projectRoot: string +) { + if (isAbsolute(source)) { + return source + } + + if (source.startsWith(".")) { + return resolve(projectRoot, source) + } + + return createRequire(resolve(projectRoot, "package.json")).resolve(source) +} + +export async function resolveCatalogSourceFilenames( + project: I18nProjectConfig, + projectRoot: string, + locale: string +) { + const dependencies = (project.catalogSources ?? []).map((source) => + resolveCatalogSourceFilename( + resolveLocaleTemplate(source, locale), + projectRoot + ) + ) + const applicationCatalog = await resolveCompiledCatalogFilename( + project, + projectRoot, + locale + ) + + return [...dependencies, applicationCatalog] +} + +async function importMessageCatalog(filename: string) { + const fileStats = await stat(filename) + const module = (await import( + `${pathToFileURL(filename).href}?workspace-i18n=${fileStats.mtimeMs}` + )) as { messages?: unknown } + + if ( + !module.messages || + typeof module.messages !== "object" || + Array.isArray(module.messages) + ) { + throw new Error(`Catalog module must export a messages object: ${filename}`) + } + + return module.messages as MessageCatalog +} + +export async function loadProjectCatalogs( + project: I18nProjectConfig, + projectRoot: string +) { + const catalogs: MessageCatalogs = {} + const sourcesByLocale: Record = {} + + await Promise.all( + project.locales.map(async (locale) => { + const filenames = await resolveCatalogSourceFilenames( + project, + projectRoot, + locale + ) + const sources = await Promise.all(filenames.map(importMessageCatalog)) + + catalogs[locale] = + mergeMessageCatalogs( + ...sources.map((messages) => ({ [locale]: messages })) + )[locale] ?? {} + sourcesByLocale[locale] = filenames + }) + ) + + return { + catalogs, + sourcesByLocale, + } +} diff --git a/packages/i18n/src/build/production-catalogs.test.ts b/packages/i18n/src/build/production-catalogs.test.ts new file mode 100644 index 0000000..a1d1087 --- /dev/null +++ b/packages/i18n/src/build/production-catalogs.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest" + +import { + compactMessageCatalogs, + createMessageIdSchema, + hashMessageId, +} from "./production-catalogs" + +describe("production catalogs", () => { + const catalogs = { + en: { + "blocks.navigation.open": "Open navigation", + "blocks.navigation.title": "Navigation", + }, + "zh-Hans": { + "blocks.navigation.open": "打开导航", + "blocks.navigation.title": "导航", + }, + } + + it("creates deterministic compact IDs", () => { + expect(hashMessageId("blocks.navigation.open")).toBe( + hashMessageId("blocks.navigation.open") + ) + expect(hashMessageId("blocks.navigation.open")).toHaveLength(10) + expect(hashMessageId("blocks.navigation.open")).not.toBe( + hashMessageId("blocks.navigation.title") + ) + }) + + it("uses one schema for every locale", () => { + const schema = createMessageIdSchema(catalogs) + const compactCatalogs = compactMessageCatalogs(catalogs, schema) + const openId = schema.messages["blocks.navigation.open"] + const titleId = schema.messages["blocks.navigation.title"] + + expect(Object.keys(compactCatalogs.en)).toEqual([openId, titleId]) + expect(compactCatalogs.en[openId!]).toBe("Open navigation") + expect(compactCatalogs["zh-Hans"][openId!]).toBe("打开导航") + expect(compactCatalogs["zh-Hans"][titleId!]).toBe("导航") + }) +}) diff --git a/packages/i18n/src/build/production-catalogs.ts b/packages/i18n/src/build/production-catalogs.ts new file mode 100644 index 0000000..beee0d3 --- /dev/null +++ b/packages/i18n/src/build/production-catalogs.ts @@ -0,0 +1,79 @@ +import { createHash } from "node:crypto" + +import type { MessageCatalog, MessageCatalogs } from "../runtime/types.ts" + +const MESSAGE_ID_HASH_LENGTH = 10 + +export interface MessageIdSchema { + algorithm: "sha256-base64url" + hashLength: number + messages: Readonly> + version: 1 +} + +export function hashMessageId(id: string) { + return createHash("sha256") + .update(id) + .digest("base64url") + .slice(0, MESSAGE_ID_HASH_LENGTH) +} + +export function createMessageIdSchema( + catalogs: MessageCatalogs +): MessageIdSchema { + const ids = Array.from( + new Set(Object.values(catalogs).flatMap((catalog) => Object.keys(catalog))) + ).sort() + const messages: Record = {} + const idsByHash = new Map() + + for (const id of ids) { + const hash = hashMessageId(id) + const existingId = idsByHash.get(hash) + + if (existingId && existingId !== id) { + throw new Error( + `Message ID hash collision between ${JSON.stringify(existingId)} and ${JSON.stringify(id)} (${hash}).` + ) + } + + idsByHash.set(hash, id) + messages[id] = hash + } + + return { + algorithm: "sha256-base64url", + hashLength: MESSAGE_ID_HASH_LENGTH, + messages, + version: 1, + } +} + +export function compactMessageCatalog( + catalog: MessageCatalog, + schema: MessageIdSchema +): MessageCatalog { + return Object.fromEntries( + Object.entries(catalog).map(([id, message]) => { + const compactId = schema.messages[id] + + if (!compactId) { + throw new Error(`Message ID is missing from the build schema: ${id}`) + } + + return [compactId, message] + }) + ) +} + +export function compactMessageCatalogs( + catalogs: MessageCatalogs, + schema: MessageIdSchema +): MessageCatalogs { + return Object.fromEntries( + Object.entries(catalogs).map(([locale, catalog]) => [ + locale, + compactMessageCatalog(catalog, schema), + ]) + ) +} diff --git a/packages/i18n/src/cli/api-plugin.ts b/packages/i18n/src/cli/api-plugin.ts index afa4dbf..3314413 100644 --- a/packages/i18n/src/cli/api-plugin.ts +++ b/packages/i18n/src/cli/api-plugin.ts @@ -3,12 +3,8 @@ 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" +import { runProjectLinguiCommand } from "./lingui-command.ts" +import { readProject } from "./project.ts" const API_PREFIX = "/__i18n" @@ -86,6 +82,15 @@ export function configureI18nApi( return } + if ( + request.method === "GET" && + url.pathname === `${API_PREFIX}/packages` + ) { + const service = new CatalogService(projectFilename) + sendJson(response, 200, await service.getPackages()) + return + } + if ( request.method === "PUT" && url.pathname === `${API_PREFIX}/messages` @@ -115,15 +120,7 @@ export function configureI18nApi( return } - const result = await runLinguiCommand( - [ - action, - ...(action === "compile" ? ["--typescript"] : []), - "--config", - getLinguiConfigFilename(projectFilename), - ], - getProjectRoot(projectFilename) - ) + const result = await runProjectLinguiCommand(projectFilename, action) sendJson(response, 200, result) return } diff --git a/packages/i18n/src/cli/catalog-service.test.ts b/packages/i18n/src/cli/catalog-service.test.ts index 65ddc77..3ab3661 100644 --- a/packages/i18n/src/cli/catalog-service.test.ts +++ b/packages/i18n/src/cli/catalog-service.test.ts @@ -1,12 +1,12 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { mkdir, 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" +import { loadProjectLinguiConfig } from "./project" const temporaryDirectories: string[] = [] @@ -23,28 +23,31 @@ describe("CatalogService", () => { 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") + join(directory, "package.json"), + `${JSON.stringify({ name: "test-app", private: true }, null, 2)}\n` + ) + await mkdir(join(directory, "packages/ui"), { recursive: true }) + await writeFile( + join(directory, "packages/ui/package.json"), + `${JSON.stringify({ name: "@workspace/ui", private: true }, null, 2)}\n` + ) + await writeFile( + projectFilename, + `${JSON.stringify( + { + catalogPath: "locales/{locale}/messages", + include: ["src"], + locales: ["en", "zh-Hans"], + sourceLocale: "en", + }, + null, + 2 + )}\n` ) - const config = getConfig({ - configPath: linguiConfigFilename, - cwd: directory, - }) + const config = await loadProjectLinguiConfig(projectFilename) const [catalog] = await getCatalogs(config) if (!catalog) { @@ -58,6 +61,11 @@ describe("CatalogService", () => { origin: [["src/dashboard.tsx", 10]], translation: "Dashboard", }, + "ui.common.close": { + message: "Close", + origin: [["packages/ui/src/dialog.tsx", 12]], + translation: "Close", + }, }) await catalog.write("zh-Hans", { "dashboard.title": { @@ -66,14 +74,26 @@ describe("CatalogService", () => { origin: [["src/dashboard.tsx", 10]], translation: "", }, + "ui.common.close": { + message: "Close", + origin: [["packages/ui/src/dialog.tsx", 12]], + translation: "关闭", + }, }) const service = new CatalogService(projectFilename) + await expect(service.getPackages()).resolves.toEqual([ + { + kind: "application", + name: "test-app", + }, + ]) const [message] = await service.getMessages("zh-Hans") expect(message).toMatchObject({ id: "dashboard.title", missing: true, + packageName: "test-app", source: "Dashboard", translation: "", }) @@ -92,5 +112,47 @@ describe("CatalogService", () => { expect(await readFile(catalog.getFilename("zh-Hans"), "utf8")).toContain( 'msgstr "数据看板"' ) + + const sourceMessages = await service.getMessages("en") + const applicationMessage = sourceMessages.find( + (item) => item.id === "dashboard.title" + ) + const dependencyMessage = sourceMessages.find( + (item) => item.id === "ui.common.close" + ) + + expect(applicationMessage).toMatchObject({ + editable: false, + packageName: "test-app", + }) + expect(dependencyMessage).toMatchObject({ + editable: true, + packageName: "@workspace/ui", + }) + + await expect( + service.updateMessage({ + catalog: applicationMessage?.catalog ?? "messages", + id: "dashboard.title", + locale: "en", + translation: "Customized dashboard", + }) + ).rejects.toThrow("must be changed in source code") + + const customizedDependency = await service.updateMessage({ + catalog: dependencyMessage?.catalog ?? "messages", + id: "ui.common.close", + locale: "en", + translation: "Dismiss", + }) + + expect(customizedDependency).toMatchObject({ + editable: true, + source: "Close", + translation: "Dismiss", + }) + expect(await readFile(catalog.getFilename("en"), "utf8")).toContain( + 'msgstr "Dismiss"' + ) }) }) diff --git a/packages/i18n/src/cli/catalog-service.ts b/packages/i18n/src/cli/catalog-service.ts index 7135920..bc0bd0f 100644 --- a/packages/i18n/src/cli/catalog-service.ts +++ b/packages/i18n/src/cli/catalog-service.ts @@ -1,16 +1,98 @@ -import { getCatalogs } from "@lingui/cli/api" -import type { CatalogType, MessageType } from "@lingui/conf" -import { getConfig } from "@lingui/conf" +import { readFile } from "node:fs/promises" +import { dirname, join, resolve } from "node:path" -import type { DevtoolMessage, UpdateMessageInput } from "../devtool/types" -import { getLinguiConfigFilename, getProjectRoot } from "./project.ts" +import { getCatalogs } from "@lingui/cli/api" +import type { + CatalogType, + LinguiConfigNormalized, + MessageType, +} from "@lingui/conf" + +import type { + DevtoolMessage, + DevtoolMessagePackage, + UpdateMessageInput, +} from "../devtool/types" +import { getProjectRoot, loadProjectLinguiConfig } from "./project.ts" interface CatalogEntry { catalog: Awaited>[number] key: string } -async function getCatalogEntries(config: ReturnType) { +interface PackageManifest { + name?: unknown +} + +class PackageNameResolver { + readonly #directoryCache = new Map>() + readonly #projectRoot: string + readonly #projectPackageName: Promise + + constructor(projectRoot: string) { + this.#projectRoot = projectRoot + this.#projectPackageName = this.#findNearestPackageName(projectRoot).then( + (name) => name ?? "project" + ) + } + + async resolve(origins: MessageType["origin"] | undefined) { + for (const [filename] of origins ?? []) { + const packageName = await this.#findNearestPackageName( + dirname(resolve(this.#projectRoot, filename)) + ) + + if (packageName) { + return packageName + } + } + + return this.#projectPackageName + } + + getProjectPackageName() { + return this.#projectPackageName + } + + #findNearestPackageName(directory: string): Promise { + const normalizedDirectory = resolve(directory) + const cached = this.#directoryCache.get(normalizedDirectory) + + if (cached) { + return cached + } + + const result = this.#readNearestPackageName(normalizedDirectory) + this.#directoryCache.set(normalizedDirectory, result) + return result + } + + async #readNearestPackageName( + directory: string + ): Promise { + try { + const manifest = JSON.parse( + await readFile(join(directory, "package.json"), "utf8") + ) as PackageManifest + + if (typeof manifest.name === "string" && manifest.name.length > 0) { + return manifest.name + } + } catch { + // Source files may sit below directories without a package manifest. + } + + const parent = dirname(directory) + + if (parent === directory) { + return undefined + } + + return this.#findNearestPackageName(parent) + } +} + +async function getCatalogEntries(config: LinguiConfigNormalized) { const catalogs = await getCatalogs(config) return catalogs.map((catalog, index) => ({ @@ -20,16 +102,20 @@ async function getCatalogEntries(config: ReturnType) { } function toDevtoolMessage({ + applicationPackageName, catalog, id, locale, + packageName, sourceEntry, sourceLocale, targetEntry, }: { + applicationPackageName: string catalog: string id: string locale: string + packageName: string sourceEntry: MessageType | undefined sourceLocale: string targetEntry: MessageType | undefined @@ -48,37 +134,52 @@ function toDevtoolMessage({ return { catalog, comments: targetEntry?.comments ?? sourceEntry?.comments ?? [], + editable: locale !== sourceLocale || packageName !== applicationPackageName, id, missing: locale !== sourceLocale && translation.length === 0, obsolete: Boolean(targetEntry?.obsolete ?? sourceEntry?.obsolete), origins: (sourceEntry?.origin ?? targetEntry?.origin ?? []).map( ([file, line]) => ({ file, line }) ), + packageName, source, + sourceLocale, translation, } } export class CatalogService { - readonly config: ReturnType - readonly catalogs: Promise + readonly context: Promise<{ + catalogs: readonly CatalogEntry[] + config: Awaited> + packageNameResolver: PackageNameResolver + }> constructor(projectFilename: string) { - this.config = getConfig({ - configPath: getLinguiConfigFilename(projectFilename), - cwd: getProjectRoot(projectFilename), - }) - this.catalogs = getCatalogEntries(this.config) + const packageNameResolver = new PackageNameResolver( + getProjectRoot(projectFilename) + ) + + this.context = loadProjectLinguiConfig(projectFilename).then( + async (config) => ({ + catalogs: await getCatalogEntries(config), + config, + packageNameResolver, + }) + ) } async getMessages(locale: string) { - this.assertLocale(locale) + const { catalogs, config, packageNameResolver } = await this.context + + this.assertLocale(config.locales, locale) + const applicationPackageName = + await packageNameResolver.getProjectPackageName() - 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(config.sourceLocale), catalog.read(locale), ]) const ids = new Set([ @@ -86,28 +187,50 @@ export class CatalogService { ...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 Promise.all( + Array.from(ids) + .sort((left, right) => left.localeCompare(right)) + .map(async (id) => { + const sourceEntry = sourceCatalog[id] + const targetEntry = targetCatalog[id] + const packageName = await packageNameResolver.resolve( + sourceEntry?.origin ?? targetEntry?.origin + ) + + return toDevtoolMessage({ + applicationPackageName, + catalog: key, + id, + locale, + packageName, + sourceEntry, + sourceLocale: config.sourceLocale, + targetEntry, + }) }) - ) + ) }) ) return messages.flat() } - async updateMessage(input: UpdateMessageInput) { - this.assertLocale(input.locale) + async getPackages(): Promise { + const { packageNameResolver } = await this.context + + return [ + { + kind: "application", + name: await packageNameResolver.getProjectPackageName(), + }, + ] + } + + async updateMessage(input: UpdateMessageInput) { + const { catalogs, config, packageNameResolver } = await this.context + + this.assertLocale(config.locales, input.locale) - const catalogs = await this.catalogs const entry = catalogs.find((candidate) => candidate.key === input.catalog) if (!entry) { @@ -115,7 +238,7 @@ export class CatalogService { } const [sourceCatalog = {}, targetCatalog = {}] = await Promise.all([ - entry.catalog.read(this.config.sourceLocale), + entry.catalog.read(config.sourceLocale), entry.catalog.read(input.locale), ]) const sourceEntry = sourceCatalog[input.id] @@ -125,6 +248,21 @@ export class CatalogService { throw new Error(`Unknown message: ${input.id}`) } + const packageName = await packageNameResolver.resolve( + sourceEntry?.origin ?? currentEntry?.origin + ) + const applicationPackageName = + await packageNameResolver.getProjectPackageName() + + if ( + input.locale === config.sourceLocale && + packageName === applicationPackageName + ) { + throw new Error( + "Application source messages must be changed in source code." + ) + } + const nextCatalog: CatalogType = { ...targetCatalog, [input.id]: { @@ -137,17 +275,19 @@ export class CatalogService { await entry.catalog.write(input.locale, nextCatalog) return toDevtoolMessage({ + applicationPackageName, catalog: entry.key, id: input.id, locale: input.locale, + packageName, sourceEntry, - sourceLocale: this.config.sourceLocale, + sourceLocale: config.sourceLocale, targetEntry: nextCatalog[input.id], }) } - private assertLocale(locale: string) { - if (!this.config.locales.includes(locale)) { + private assertLocale(locales: readonly string[], locale: string) { + if (!locales.includes(locale)) { throw new Error(`Unknown locale: ${locale}`) } } diff --git a/packages/i18n/src/cli/index.ts b/packages/i18n/src/cli/index.ts index b971aec..fb91e3f 100755 --- a/packages/i18n/src/cli/index.ts +++ b/packages/i18n/src/cli/index.ts @@ -3,13 +3,9 @@ import { cac } from "cac" import { startDevtool } from "./dev-server" -import { runLinguiCommand } from "./lingui-command" +import { runProjectLinguiCommand } from "./lingui-command" import { addLocale } from "./new-locale" -import { - getLinguiConfigFilename, - getProjectRoot, - resolveProjectFilename, -} from "./project" +import { resolveProjectFilename } from "./project" const cli = cac("workspace-i18n") @@ -18,15 +14,7 @@ async function runProjectCommand( projectOption?: string ) { const projectFilename = await resolveProjectFilename(projectOption) - const result = await runLinguiCommand( - [ - command, - ...(command === "compile" ? ["--typescript"] : []), - "--config", - getLinguiConfigFilename(projectFilename), - ], - getProjectRoot(projectFilename) - ) + const result = await runProjectLinguiCommand(projectFilename, command) if (result.output.trim()) { process.stdout.write(result.output) diff --git a/packages/i18n/src/cli/lingui-command.test.ts b/packages/i18n/src/cli/lingui-command.test.ts new file mode 100644 index 0000000..cb07a96 --- /dev/null +++ b/packages/i18n/src/cli/lingui-command.test.ts @@ -0,0 +1,69 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { getCatalogs } from "@lingui/cli/api" +import { afterEach, describe, expect, it } from "vitest" + +import { runProjectLinguiCommand } from "./lingui-command" +import { loadProjectLinguiConfig } from "./project" + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ) +}) + +describe("runProjectLinguiCommand", () => { + it("compiles from i18n.config.json without a persistent Lingui config", async () => { + const directory = await mkdtemp(join(tmpdir(), "workspace-i18n-command-")) + temporaryDirectories.push(directory) + const projectFilename = join(directory, "i18n.config.json") + + await writeFile( + projectFilename, + `${JSON.stringify( + { + catalogPath: "locales/{locale}/messages", + include: ["src"], + locales: ["en"], + sourceLocale: "en", + }, + null, + 2 + )}\n` + ) + + const config = await loadProjectLinguiConfig(projectFilename) + const [catalog] = await getCatalogs(config) + + if (!catalog) { + throw new Error("Expected a Lingui catalog.") + } + + await catalog.write("en", { + greeting: { + message: "Hello", + translation: "Hello", + }, + }) + + const result = await runProjectLinguiCommand(projectFilename, "compile") + const compiledFilename = join(directory, "locales/en/messages.ts") + const firstModifiedTime = (await stat(compiledFilename)).mtimeMs + + expect(result.output).toContain("Compiling message catalogs") + expect(await readFile(compiledFilename, "utf8")).toContain( + "export const messages" + ) + + await new Promise((resolve) => setTimeout(resolve, 20)) + await runProjectLinguiCommand(projectFilename, "compile") + + expect((await stat(compiledFilename)).mtimeMs).toBe(firstModifiedTime) + }) +}) diff --git a/packages/i18n/src/cli/lingui-command.ts b/packages/i18n/src/cli/lingui-command.ts index 6a098ed..76b1727 100644 --- a/packages/i18n/src/cli/lingui-command.ts +++ b/packages/i18n/src/cli/lingui-command.ts @@ -1,6 +1,19 @@ import { spawn } from "node:child_process" +import { + copyFile, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, isAbsolute, join, resolve } from "node:path" import { fileURLToPath } from "node:url" +import { createLinguiConfig } from "../config/lingui-config.ts" +import { getProjectRoot, readProject } from "./project.ts" + const LINGUI_BIN = fileURLToPath( new URL("./lingui.js", import.meta.resolve("@lingui/cli")) ) @@ -9,12 +22,86 @@ export interface LinguiCommandResult { output: string } -export async function runLinguiCommand( +interface RunProjectLinguiCommandOptions { + locale?: string + typescript?: boolean +} + +interface StagedCompiledCatalog { + outputFilename: string + stagedOutputFilename: string +} + +function resolveProjectPath(pathname: string, projectRoot: string) { + const resolvedRoot = pathname.replaceAll("", projectRoot) + + return isAbsolute(resolvedRoot) + ? resolvedRoot + : resolve(projectRoot, resolvedRoot) +} + +function resolveLocalePath(pathname: string, locale: string) { + return pathname.replaceAll("{locale}", locale) +} + +async function copyFileIfPresent(source: string, destination: string) { + await mkdir(dirname(destination), { recursive: true }) + + try { + await copyFile(source, destination) + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" + ) { + return + } + + throw error + } +} + +async function readFileIfPresent(filename: string) { + try { + return await readFile(filename, "utf8") + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" + ) { + return undefined + } + + throw error + } +} + +async function publishCompiledCatalogs( + catalogs: readonly StagedCompiledCatalog[] +) { + for (const catalog of catalogs) { + const nextContent = await readFile(catalog.stagedOutputFilename, "utf8") + const currentContent = await readFileIfPresent(catalog.outputFilename) + + if (currentContent === nextContent) { + continue + } + + await mkdir(dirname(catalog.outputFilename), { recursive: true }) + await writeFile(catalog.outputFilename, nextContent, "utf8") + } +} + +async function runLinguiCommand( args: readonly string[], cwd: string ): Promise { return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [LINGUI_BIN, ...args], { + const child = spawn("node", [LINGUI_BIN, ...args], { cwd, env: process.env, stdio: ["ignore", "pipe", "pipe"], @@ -44,3 +131,89 @@ export async function runLinguiCommand( }) }) } + +export async function runProjectLinguiCommand( + projectFilename: string, + command: "compile" | "extract", + { + locale, + typescript = command === "compile", + }: RunProjectLinguiCommandOptions = {} +) { + const projectRoot = getProjectRoot(projectFilename) + const project = await readProject(projectFilename) + const temporaryDirectory = await mkdtemp( + join(tmpdir(), "workspace-i18n-config-") + ) + const configFilename = join(temporaryDirectory, "lingui.config.mjs") + const projectConfig = createLinguiConfig(project) + const locales = locale ? [locale] : project.locales + const compiledExtension = typescript ? "ts" : "js" + const stagedCatalogPath = join( + temporaryDirectory, + "catalogs", + "{locale}", + "messages" + ) + const sourceCatalogPath = resolveProjectPath(project.catalogPath, projectRoot) + const stagedCompiledCatalogs: StagedCompiledCatalog[] = + command === "compile" + ? locales.map((catalogLocale) => ({ + outputFilename: `${resolveLocalePath(sourceCatalogPath, catalogLocale)}.${compiledExtension}`, + stagedOutputFilename: `${resolveLocalePath(stagedCatalogPath, catalogLocale)}.${compiledExtension}`, + })) + : [] + const config = { + ...projectConfig, + ...(command === "compile" + ? { + catalogs: projectConfig.catalogs?.map((catalog) => + typeof catalog === "string" + ? catalog + : { ...catalog, path: stagedCatalogPath } + ), + } + : undefined), + rootDir: projectRoot, + } + + try { + if (command === "compile") { + await Promise.all( + locales.map((catalogLocale) => + copyFileIfPresent( + `${resolveLocalePath(sourceCatalogPath, catalogLocale)}.po`, + `${resolveLocalePath(stagedCatalogPath, catalogLocale)}.po` + ) + ) + ) + } + + await writeFile( + configFilename, + `export default ${JSON.stringify(config, null, 2)}\n`, + "utf8" + ) + + const result = await runLinguiCommand( + [ + command, + "--workers", + "1", + ...(typescript ? ["--typescript"] : []), + ...(locale ? ["--locale", locale] : []), + "--config", + configFilename, + ], + projectRoot + ) + + if (command === "compile") { + await publishCompiledCatalogs(stagedCompiledCatalogs) + } + + return result + } finally { + await rm(temporaryDirectory, { force: true, recursive: true }) + } +} diff --git a/packages/i18n/src/cli/new-locale.ts b/packages/i18n/src/cli/new-locale.ts index 1555472..4ab4a5c 100644 --- a/packages/i18n/src/cli/new-locale.ts +++ b/packages/i18n/src/cli/new-locale.ts @@ -1,11 +1,5 @@ -import { - getLinguiConfigFilename, - getProjectRoot, - readProject, - resolveProjectFilename, - writeProject, -} from "./project" -import { runLinguiCommand } from "./lingui-command" +import { readProject, resolveProjectFilename, writeProject } from "./project" +import { runProjectLinguiCommand } from "./lingui-command" export interface AddLocaleOptions { project?: string @@ -38,16 +32,9 @@ export async function addLocale( } try { - const result = await runLinguiCommand( - [ - "extract", - "--locale", - locale, - "--config", - getLinguiConfigFilename(projectFilename), - ], - getProjectRoot(projectFilename) - ) + const result = await runProjectLinguiCommand(projectFilename, "extract", { + locale, + }) return { created, diff --git a/packages/i18n/src/cli/project.test.ts b/packages/i18n/src/cli/project.test.ts index c7539d1..480cf7b 100644 --- a/packages/i18n/src/cli/project.test.ts +++ b/packages/i18n/src/cli/project.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path" import { afterEach, describe, expect, it } from "vitest" -import { resolveProjectFilename } from "./project" +import { readProject, resolveProjectFilename } from "./project" const temporaryDirectories: string[] = [] @@ -44,3 +44,34 @@ describe("resolveProjectFilename", () => { ).rejects.toThrow("Run the command inside a configured application") }) }) + +describe("readProject", () => { + it("accepts reusable catalog source templates", async () => { + const applicationRoot = await mkdtemp( + join(tmpdir(), "workspace-i18n-project-") + ) + temporaryDirectories.push(applicationRoot) + const projectFilename = join(applicationRoot, "i18n.config.json") + + await writeFile( + projectFilename, + `${JSON.stringify({ + catalogPath: "src/locales/{locale}/messages", + catalogSources: [ + "@workspace/ui/locales/{locale}", + "@workspace/blocks/navigation/locales/{locale}", + ], + include: ["src"], + locales: ["en", "zh-Hans"], + sourceLocale: "en", + })}\n` + ) + + await expect(readProject(projectFilename)).resolves.toMatchObject({ + catalogSources: [ + "@workspace/ui/locales/{locale}", + "@workspace/blocks/navigation/locales/{locale}", + ], + }) + }) +}) diff --git a/packages/i18n/src/cli/project.ts b/packages/i18n/src/cli/project.ts index 03cc94c..316e455 100644 --- a/packages/i18n/src/cli/project.ts +++ b/packages/i18n/src/cli/project.ts @@ -2,7 +2,10 @@ 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" +import { + createNormalizedLinguiConfig, + type I18nProjectConfig, +} from "../config/lingui-config.ts" const PACKAGE_ROOT = fileURLToPath(new URL("../../", import.meta.url)) const PROJECT_FILENAME = "i18n.config.json" @@ -31,6 +34,11 @@ function validateProjectConfig( !Array.isArray(value.locales) || !value.locales.every((locale) => typeof locale === "string") || typeof value.catalogPath !== "string" || + (value.catalogSources !== undefined && + (!Array.isArray(value.catalogSources) || + !value.catalogSources.every( + (catalogSource) => typeof catalogSource === "string" + ))) || !Array.isArray(value.include) || !value.include.every((pattern) => typeof pattern === "string") || (value.exclude !== undefined && @@ -44,6 +52,7 @@ function validateProjectConfig( return { catalogPath: value.catalogPath, + catalogSources: value.catalogSources as string[] | undefined, exclude: value.exclude as string[] | undefined, include: value.include, locales: value.locales, @@ -115,14 +124,17 @@ export async function writeProject( ) } -export function getLinguiConfigFilename(projectFilename: string) { - return join(dirname(projectFilename), "lingui.config.ts") -} - export function getProjectRoot(projectFilename: string) { return dirname(projectFilename) } +export async function loadProjectLinguiConfig(projectFilename: string) { + return createNormalizedLinguiConfig( + await readProject(projectFilename), + getProjectRoot(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 index 0d62b7b..da52493 100644 --- a/packages/i18n/src/config/lingui-config.test.ts +++ b/packages/i18n/src/config/lingui-config.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it } from "vitest" -import { createLinguiConfig } from "./lingui-config" +import { + createLinguiConfig, + createNormalizedLinguiConfig, +} from "./lingui-config" describe("createLinguiConfig", () => { it("maps the editable project manifest to a Lingui configuration", () => { expect( createLinguiConfig({ catalogPath: "locales/{locale}/messages", + catalogSources: ["@workspace/ui/locales/{locale}"], exclude: ["**/*.test.ts"], include: ["src"], locales: ["en", "zh-Hans"], @@ -27,4 +31,26 @@ describe("createLinguiConfig", () => { sourceLocale: "en", }) }) + + it("normalizes configuration relative to the consuming application", () => { + const config = createNormalizedLinguiConfig( + { + catalogPath: "locales/{locale}/messages", + include: ["src"], + locales: ["en"], + sourceLocale: "en", + }, + "/workspace/apps/web" + ) + + expect(config.rootDir).toBe("/workspace/apps/web") + expect(config.catalogs[0]).toMatchObject({ + include: ["/workspace/apps/web/src"], + path: "/workspace/apps/web/locales/{locale}/messages", + }) + expect(config.runtimeConfigModule.Trans).toEqual([ + "@workspace/i18n", + "Translate", + ]) + }) }) diff --git a/packages/i18n/src/config/lingui-config.ts b/packages/i18n/src/config/lingui-config.ts index 60902f4..fafd332 100644 --- a/packages/i18n/src/config/lingui-config.ts +++ b/packages/i18n/src/config/lingui-config.ts @@ -1,9 +1,14 @@ import { isAbsolute } from "node:path" -import type { LinguiConfig } from "@lingui/conf" +import { + makeConfig, + type LinguiConfig, + type LinguiConfigNormalized, +} from "@lingui/conf" export interface I18nProjectConfig { catalogPath: string + catalogSources?: readonly string[] exclude?: readonly string[] include: readonly string[] locales: readonly string[] @@ -34,3 +39,13 @@ export function createLinguiConfig(project: I18nProjectConfig): LinguiConfig { sourceLocale: project.sourceLocale, } } + +export function createNormalizedLinguiConfig( + project: I18nProjectConfig, + rootDir: string +): LinguiConfigNormalized { + return makeConfig({ + ...createLinguiConfig(project), + rootDir, + }) +} diff --git a/packages/i18n/src/devtool/ansi-text.test.tsx b/packages/i18n/src/devtool/ansi-text.test.tsx new file mode 100644 index 0000000..6f3c6c0 --- /dev/null +++ b/packages/i18n/src/devtool/ansi-text.test.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import { renderToStaticMarkup } from "react-dom/server" +import { describe, expect, it } from "vitest" + +import { AnsiText, resolveAnsiComponent } from "./ansi-text" + +describe("AnsiText", () => { + it("resolves direct, CommonJS, and nested default exports", () => { + const Component = ({ children }: { children?: string }) => ( + {children} + ) + + expect(resolveAnsiComponent(Component)).toBe(Component) + expect(resolveAnsiComponent({ default: Component })).toBe(Component) + expect(resolveAnsiComponent({ default: { default: Component } })).toBe( + Component + ) + }) + + it("renders ANSI SGR output during server rendering", () => { + const markup = renderToStaticMarkup( + {"\u001b[32mDone\u001b[39m"} + ) + + expect(markup).toContain("Done") + expect(markup).toContain("color:") + expect(markup).not.toContain("\u001b") + }) +}) diff --git a/packages/i18n/src/devtool/ansi-text.tsx b/packages/i18n/src/devtool/ansi-text.tsx new file mode 100644 index 0000000..a3a427c --- /dev/null +++ b/packages/i18n/src/devtool/ansi-text.tsx @@ -0,0 +1,39 @@ +import * as React from "react" +import * as AnsiModule from "ansi-to-react" + +export interface AnsiTextProps { + children?: string + className?: string + linkify?: boolean | "fuzzy" + useClasses?: boolean +} + +type AnsiComponent = React.ComponentType + +export function resolveAnsiComponent(moduleValue: unknown): AnsiComponent { + let candidate = moduleValue + + for (let depth = 0; depth < 3; depth += 1) { + if (typeof candidate === "function") { + return candidate as AnsiComponent + } + + if ( + typeof candidate !== "object" || + candidate === null || + !("default" in candidate) + ) { + break + } + + candidate = candidate.default + } + + throw new TypeError("ansi-to-react did not export a React component.") +} + +const AnsiComponent = resolveAnsiComponent(AnsiModule) + +export function AnsiText(props: AnsiTextProps) { + return +} diff --git a/packages/i18n/src/devtool/api-client.ts b/packages/i18n/src/devtool/api-client.ts index e278116..e5bf499 100644 --- a/packages/i18n/src/devtool/api-client.ts +++ b/packages/i18n/src/devtool/api-client.ts @@ -1,5 +1,10 @@ export type DevtoolAction = "compile" | "extract" +export interface DevtoolProject { + locales: readonly string[] + sourceLocale: string +} + interface ApiError { error?: string } @@ -29,3 +34,9 @@ export async function runDevtoolAction( { method: "POST" } ) } + +export async function requestDevtoolProject(baseUrl = "/__i18n") { + const normalizedBaseUrl = baseUrl.replace(/\/+$/, "") + + return requestDevtoolJson(`${normalizedBaseUrl}/project`) +} diff --git a/packages/i18n/src/devtool/app/app.tsx b/packages/i18n/src/devtool/app/app.tsx index f94e327..21ca8a4 100644 --- a/packages/i18n/src/devtool/app/app.tsx +++ b/packages/i18n/src/devtool/app/app.tsx @@ -40,19 +40,26 @@ export function DevtoolApp() { ) } - const locale = state.project.locales[0] + const locale = + state.project.locales.find( + (locale) => locale !== state.project.sourceLocale + ) ?? state.project.sourceLocale - if (!locale) { + if (!state.project.locales.includes(locale)) { return (
- Add a locale with workspace-i18n new <locale>. + Add a target locale with workspace-i18n new <locale>.
) } return ( - + ) } diff --git a/packages/i18n/src/devtool/devtool-action-output.tsx b/packages/i18n/src/devtool/devtool-action-output.tsx new file mode 100644 index 0000000..d428bce --- /dev/null +++ b/packages/i18n/src/devtool/devtool-action-output.tsx @@ -0,0 +1,65 @@ +import { + ChevronDownIcon, + ChevronUpIcon, + TerminalIcon, + XIcon, +} from "lucide-react" + +import { AnsiText } from "./ansi-text" +import { useDevtoolLocalization } from "./devtool-localization" + +export interface DevtoolActionOutputProps { + error?: boolean + expanded: boolean + onClose: () => void + onExpandedChange: (expanded: boolean) => void + output: string +} + +export function DevtoolActionOutput({ + error = false, + expanded, + onClose, + onExpandedChange, + output, +}: DevtoolActionOutputProps) { + const ToggleIcon = expanded ? ChevronUpIcon : ChevronDownIcon + const { messages } = useDevtoolLocalization() + + return ( +
+
+ + +
+ + +
+
+ {expanded && ( +
+          {output}
+        
+ )} +
+ ) +} diff --git a/packages/i18n/src/devtool/devtool-locale-select.tsx b/packages/i18n/src/devtool/devtool-locale-select.tsx new file mode 100644 index 0000000..4661a9f --- /dev/null +++ b/packages/i18n/src/devtool/devtool-locale-select.tsx @@ -0,0 +1,50 @@ +import type { LocaleDefinition } from "../runtime" +import { useDevtoolLocalization } from "./devtool-localization" +import { DevtoolSelect } from "./devtool-select" + +export interface DevtoolLocaleSelectProps { + locale: string + locales: readonly LocaleDefinition[] + onLocaleChange: (locale: string) => void + sourceLocaleLabel?: string +} + +export function DevtoolLocaleSelect({ + locale, + locales, + onLocaleChange, + sourceLocaleLabel, +}: DevtoolLocaleSelectProps) { + const { messages } = useDevtoolLocalization() + + return ( + <> + {sourceLocaleLabel && ( +
+ {messages.locale.sourceLabel} + + {sourceLocaleLabel} + +
+ )} + + + ) +} diff --git a/packages/i18n/src/devtool/devtool-localization.test.ts b/packages/i18n/src/devtool/devtool-localization.test.ts new file mode 100644 index 0000000..43be1c3 --- /dev/null +++ b/packages/i18n/src/devtool/devtool-localization.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest" + +import { resolveI18nDevtoolLocale } from "./devtool-localization" + +describe("resolveI18nDevtoolLocale", () => { + it("normalizes common BCP 47 language tags", () => { + expect(resolveI18nDevtoolLocale("zh-CN")).toBe("zh-Hans") + expect(resolveI18nDevtoolLocale("zh-TW")).toBe("zh-Hant") + expect(resolveI18nDevtoolLocale("es-419")).toBe("es") + }) + + it("selects the first supported preferred locale", () => { + expect( + resolveI18nDevtoolLocale(undefined, ["pt-BR", "fr-CA", "en-US"]) + ).toBe("fr") + }) + + it("falls back to English", () => { + expect(resolveI18nDevtoolLocale(undefined, ["pt-BR"])).toBe("en") + }) +}) diff --git a/packages/i18n/src/devtool/devtool-localization.tsx b/packages/i18n/src/devtool/devtool-localization.tsx new file mode 100644 index 0000000..9293225 --- /dev/null +++ b/packages/i18n/src/devtool/devtool-localization.tsx @@ -0,0 +1,933 @@ +import * as React from "react" + +export const I18N_DEVTOOL_LOCALES = [ + "en", + "de", + "es", + "fr", + "ja", + "ko", + "zh-Hans", + "zh-Hant", +] as const + +export type I18nDevtoolLocale = (typeof I18N_DEVTOOL_LOCALES)[number] + +interface DevtoolMessages { + actions: { + compile: string + compileCompleted: string + compileFailed: string + compiling: string + enterFullscreen: string + exitFullscreen: string + extract: string + extractCompleted: string + extractFailed: string + extracting: string + label: string + } + commandOutput: { + close: string + collapse: string + expand: string + title: string + } + locale: { + controlLabel: string + label: string + noTargetLocales: string + sourceLabel: string + } + messagePanel: { + additionalSourceLocations: (count: number) => string + chooseCopyContent: string + collapseNamespace: (path: string) => string + collapsePackage: (packageName: string) => string + copied: string + copyDescriptor: string + copyFailed: string + copyMessageId: string + copySource: string + copySourceLocation: string + copyTranslation: string + couldNotSave: string + emptyCatalog: string + emptyPackage: string + expandNamespace: (path: string) => string + expandPackage: (packageName: string) => string + loading: (locale: string) => string + messageCount: (count: number) => string + messageEditor: string + messageNavigation: string + missingCount: (count: number) => string + missingOnly: string + noMatches: string + noMissingMessages: string + noSearchResults: string + reset: string + retry: string + save: string + saved: string + saving: string + search: string + selectMessage: string + source: string + sourceLocations: string + translation: string + translationFor: (id: string, locale: string) => string + unknownSourceLocation: string + } + settings: { + auto: string + autoTheme: string + center: string + dark: string + darkTheme: string + interfaceLanguage: string + interfaceLanguageControl: string + label: string + left: string + light: string + lightTheme: string + open: string + panelPosition: string + panelPositionControl: string + right: string + theme: string + themeControl: string + } + trigger: { + close: string + open: string + } +} + +const messagesByLocale: Record = { + en: { + actions: { + compile: "Compile", + compileCompleted: "Compilation completed.", + compileFailed: "Compilation failed.", + compiling: "Compiling…", + enterFullscreen: "Enter full screen", + exitFullscreen: "Exit full screen", + extract: "Extract", + extractCompleted: "Extraction completed.", + extractFailed: "Extraction failed.", + extracting: "Extracting…", + label: "Actions", + }, + commandOutput: { + close: "Close command output", + collapse: "Collapse command output", + expand: "Expand command output", + title: "Command output", + }, + locale: { + controlLabel: "Target message locale", + label: "Target locale", + noTargetLocales: "No target locales configured.", + sourceLabel: "Source locale", + }, + messagePanel: { + additionalSourceLocations: (count) => + `${count} additional source locations`, + chooseCopyContent: "Choose content to copy", + collapseNamespace: (path) => `Collapse ${path}`, + collapsePackage: (packageName) => `Collapse ${packageName}`, + copied: "Copied", + copyDescriptor: "Copy message descriptor", + copyFailed: "Could not copy", + copyMessageId: "Copy message ID", + copySource: "Copy source text", + copySourceLocation: "Copy source location", + copyTranslation: "Copy current translation", + couldNotSave: "Could not save", + emptyCatalog: + "No messages are available yet. Extract the project to populate this catalog.", + emptyPackage: "No messages were found in this package.", + expandNamespace: (path) => `Expand ${path}`, + expandPackage: (packageName) => `Expand ${packageName}`, + loading: (locale) => `Loading ${locale}…`, + messageCount: (count) => `${count} messages`, + messageEditor: "Message editor", + messageNavigation: "Message navigation", + missingCount: (count) => `${count} missing`, + missingOnly: "Missing only", + noMatches: "No messages match the current filters.", + noMissingMessages: "All messages have translations.", + noSearchResults: "No messages match your search.", + reset: "Reset", + retry: "Retry", + save: "Save", + saved: "Saved", + saving: "Saving…", + search: "Search messages", + selectMessage: "Select a message to view and edit its translation.", + source: "Source", + sourceLocations: "Source locations", + translation: "Translation", + translationFor: (id, locale) => `${id} translation for ${locale}`, + unknownSourceLocation: "Unknown source location", + }, + settings: { + auto: "Auto", + autoTheme: "Auto theme", + center: "Center", + dark: "Dark", + darkTheme: "Dark theme", + interfaceLanguage: "Interface language", + interfaceLanguageControl: "I18n Devtool interface language", + label: "I18n Devtool settings", + left: "Left", + light: "Light", + lightTheme: "Light theme", + open: "Open I18n Devtool settings", + panelPosition: "Panel position", + panelPositionControl: "I18n Devtool panel position", + right: "Right", + theme: "Theme", + themeControl: "I18n Devtool theme", + }, + trigger: { + close: "Close I18n Devtool", + open: "Open I18n Devtool", + }, + }, + de: { + actions: { + compile: "Kompilieren", + compileCompleted: "Kompilierung abgeschlossen.", + compileFailed: "Kompilierung fehlgeschlagen.", + compiling: "Wird kompiliert…", + enterFullscreen: "Vollbildmodus aktivieren", + exitFullscreen: "Vollbildmodus beenden", + extract: "Extrahieren", + extractCompleted: "Extraktion abgeschlossen.", + extractFailed: "Extraktion fehlgeschlagen.", + extracting: "Wird extrahiert…", + label: "Aktionen", + }, + commandOutput: { + close: "Befehlsausgabe schließen", + collapse: "Befehlsausgabe einklappen", + expand: "Befehlsausgabe ausklappen", + title: "Befehlsausgabe", + }, + locale: { + controlLabel: "Zielsprache der Nachrichten", + label: "Zielsprache", + noTargetLocales: "Keine Zielsprachen konfiguriert.", + sourceLabel: "Ausgangssprache", + }, + messagePanel: { + additionalSourceLocations: (count) => `${count} weitere Quellorte`, + chooseCopyContent: "Inhalt zum Kopieren auswählen", + collapseNamespace: (path) => `${path} einklappen`, + collapsePackage: (packageName) => `${packageName} einklappen`, + copied: "Kopiert", + copyDescriptor: "Nachrichten-Deskriptor kopieren", + copyFailed: "Kopieren fehlgeschlagen", + copyMessageId: "Nachrichten-ID kopieren", + copySource: "Quelltext kopieren", + copySourceLocation: "Quellort kopieren", + copyTranslation: "Aktuelle Übersetzung kopieren", + couldNotSave: "Speichern fehlgeschlagen", + emptyCatalog: + "Es sind noch keine Nachrichten verfügbar. Extrahieren Sie das Projekt, um diesen Katalog zu füllen.", + emptyPackage: "In diesem Paket wurden keine Nachrichten gefunden.", + expandNamespace: (path) => `${path} ausklappen`, + expandPackage: (packageName) => `${packageName} ausklappen`, + loading: (locale) => `${locale} wird geladen…`, + messageCount: (count) => `${count} Nachrichten`, + messageEditor: "Nachrichteneditor", + messageNavigation: "Nachrichtennavigation", + missingCount: (count) => `${count} fehlen`, + missingOnly: "Nur fehlende", + noMatches: "Keine Nachrichten entsprechen den aktuellen Filtern.", + noMissingMessages: "Alle Nachrichten sind übersetzt.", + noSearchResults: "Keine Nachrichten entsprechen Ihrer Suche.", + reset: "Zurücksetzen", + retry: "Erneut versuchen", + save: "Speichern", + saved: "Gespeichert", + saving: "Wird gespeichert…", + search: "Nachrichten durchsuchen", + selectMessage: + "Wählen Sie eine Nachricht aus, um ihre Übersetzung anzuzeigen und zu bearbeiten.", + source: "Quelle", + sourceLocations: "Quellorte", + translation: "Übersetzung", + translationFor: (id, locale) => `${id}: Übersetzung für ${locale}`, + unknownSourceLocation: "Unbekannter Quellort", + }, + settings: { + auto: "Automatisch", + autoTheme: "Automatisches Design", + center: "Mitte", + dark: "Dunkel", + darkTheme: "Dunkles Design", + interfaceLanguage: "Oberflächensprache", + interfaceLanguageControl: "Oberflächensprache des I18n Devtools", + label: "I18n-Devtool-Einstellungen", + left: "Links", + light: "Hell", + lightTheme: "Helles Design", + open: "I18n-Devtool-Einstellungen öffnen", + panelPosition: "Panelposition", + panelPositionControl: "Position des I18n-Devtool-Panels", + right: "Rechts", + theme: "Design", + themeControl: "I18n-Devtool-Design", + }, + trigger: { + close: "I18n Devtool schließen", + open: "I18n Devtool öffnen", + }, + }, + es: { + actions: { + compile: "Compilar", + compileCompleted: "Compilación completada.", + compileFailed: "Error de compilación.", + compiling: "Compilando…", + enterFullscreen: "Entrar en pantalla completa", + exitFullscreen: "Salir de pantalla completa", + extract: "Extraer", + extractCompleted: "Extracción completada.", + extractFailed: "Error de extracción.", + extracting: "Extrayendo…", + label: "Acciones", + }, + commandOutput: { + close: "Cerrar salida del comando", + collapse: "Contraer salida del comando", + expand: "Expandir salida del comando", + title: "Salida del comando", + }, + locale: { + controlLabel: "Idioma de destino de los mensajes", + label: "Idioma de destino", + noTargetLocales: "No hay idiomas de destino configurados.", + sourceLabel: "Idioma de origen", + }, + messagePanel: { + additionalSourceLocations: (count) => + `${count} ubicaciones de origen adicionales`, + chooseCopyContent: "Elegir contenido para copiar", + collapseNamespace: (path) => `Contraer ${path}`, + collapsePackage: (packageName) => `Contraer ${packageName}`, + copied: "Copiado", + copyDescriptor: "Copiar descriptor del mensaje", + copyFailed: "No se pudo copiar", + copyMessageId: "Copiar ID del mensaje", + copySource: "Copiar texto de origen", + copySourceLocation: "Copiar ubicación de origen", + copyTranslation: "Copiar traducción actual", + couldNotSave: "No se pudo guardar", + emptyCatalog: + "Aún no hay mensajes disponibles. Extraiga el proyecto para completar este catálogo.", + emptyPackage: "No se encontraron mensajes en este paquete.", + expandNamespace: (path) => `Expandir ${path}`, + expandPackage: (packageName) => `Expandir ${packageName}`, + loading: (locale) => `Cargando ${locale}…`, + messageCount: (count) => `${count} mensajes`, + messageEditor: "Editor de mensajes", + messageNavigation: "Navegación de mensajes", + missingCount: (count) => `${count} faltantes`, + missingOnly: "Solo faltantes", + noMatches: "Ningún mensaje coincide con los filtros actuales.", + noMissingMessages: "Todos los mensajes tienen traducción.", + noSearchResults: "Ningún mensaje coincide con la búsqueda.", + reset: "Restablecer", + retry: "Reintentar", + save: "Guardar", + saved: "Guardado", + saving: "Guardando…", + search: "Buscar mensajes", + selectMessage: "Seleccione un mensaje para ver y editar su traducción.", + source: "Origen", + sourceLocations: "Ubicaciones de origen", + translation: "Traducción", + translationFor: (id, locale) => `Traducción de ${id} para ${locale}`, + unknownSourceLocation: "Ubicación de origen desconocida", + }, + settings: { + auto: "Automático", + autoTheme: "Tema automático", + center: "Centro", + dark: "Oscuro", + darkTheme: "Tema oscuro", + interfaceLanguage: "Idioma de la interfaz", + interfaceLanguageControl: "Idioma de la interfaz de I18n Devtool", + label: "Ajustes de I18n Devtool", + left: "Izquierda", + light: "Claro", + lightTheme: "Tema claro", + open: "Abrir ajustes de I18n Devtool", + panelPosition: "Posición del panel", + panelPositionControl: "Posición del panel de I18n Devtool", + right: "Derecha", + theme: "Tema", + themeControl: "Tema de I18n Devtool", + }, + trigger: { + close: "Cerrar I18n Devtool", + open: "Abrir I18n Devtool", + }, + }, + fr: { + actions: { + compile: "Compiler", + compileCompleted: "Compilation terminée.", + compileFailed: "Échec de la compilation.", + compiling: "Compilation…", + enterFullscreen: "Passer en plein écran", + exitFullscreen: "Quitter le plein écran", + extract: "Extraire", + extractCompleted: "Extraction terminée.", + extractFailed: "Échec de l’extraction.", + extracting: "Extraction…", + label: "Actions", + }, + commandOutput: { + close: "Fermer la sortie de commande", + collapse: "Réduire la sortie de commande", + expand: "Développer la sortie de commande", + title: "Sortie de commande", + }, + locale: { + controlLabel: "Langue cible des messages", + label: "Langue cible", + noTargetLocales: "Aucune langue cible n’est configurée.", + sourceLabel: "Langue source", + }, + messagePanel: { + additionalSourceLocations: (count) => + `${count} emplacements source supplémentaires`, + chooseCopyContent: "Choisir le contenu à copier", + collapseNamespace: (path) => `Réduire ${path}`, + collapsePackage: (packageName) => `Réduire ${packageName}`, + copied: "Copié", + copyDescriptor: "Copier le descripteur du message", + copyFailed: "Échec de la copie", + copyMessageId: "Copier l’identifiant du message", + copySource: "Copier le texte source", + copySourceLocation: "Copier l’emplacement source", + copyTranslation: "Copier la traduction actuelle", + couldNotSave: "Échec de l’enregistrement", + emptyCatalog: + "Aucun message n’est encore disponible. Extrayez le projet pour remplir ce catalogue.", + emptyPackage: "Aucun message n’a été trouvé dans ce package.", + expandNamespace: (path) => `Développer ${path}`, + expandPackage: (packageName) => `Développer ${packageName}`, + loading: (locale) => `Chargement de ${locale}…`, + messageCount: (count) => `${count} messages`, + messageEditor: "Éditeur de messages", + messageNavigation: "Navigation des messages", + missingCount: (count) => `${count} manquants`, + missingOnly: "Manquants uniquement", + noMatches: "Aucun message ne correspond aux filtres actuels.", + noMissingMessages: "Tous les messages sont traduits.", + noSearchResults: "Aucun message ne correspond à votre recherche.", + reset: "Réinitialiser", + retry: "Réessayer", + save: "Enregistrer", + saved: "Enregistré", + saving: "Enregistrement…", + search: "Rechercher des messages", + selectMessage: + "Sélectionnez un message pour afficher et modifier sa traduction.", + source: "Source", + sourceLocations: "Emplacements source", + translation: "Traduction", + translationFor: (id, locale) => `Traduction de ${id} pour ${locale}`, + unknownSourceLocation: "Emplacement source inconnu", + }, + settings: { + auto: "Auto", + autoTheme: "Thème automatique", + center: "Centre", + dark: "Sombre", + darkTheme: "Thème sombre", + interfaceLanguage: "Langue de l’interface", + interfaceLanguageControl: "Langue de l’interface de l’outil I18n", + label: "Paramètres de I18n Devtool", + left: "Gauche", + light: "Clair", + lightTheme: "Thème clair", + open: "Ouvrir les paramètres de I18n Devtool", + panelPosition: "Position du panneau", + panelPositionControl: "Position du panneau I18n Devtool", + right: "Droite", + theme: "Thème", + themeControl: "Thème de I18n Devtool", + }, + trigger: { + close: "Fermer I18n Devtool", + open: "Ouvrir I18n Devtool", + }, + }, + ja: { + actions: { + compile: "コンパイル", + compileCompleted: "コンパイルが完了しました。", + compileFailed: "コンパイルに失敗しました。", + compiling: "コンパイル中…", + enterFullscreen: "全画面表示にする", + exitFullscreen: "全画面表示を終了", + extract: "抽出", + extractCompleted: "抽出が完了しました。", + extractFailed: "抽出に失敗しました。", + extracting: "抽出中…", + label: "操作", + }, + commandOutput: { + close: "コマンド出力を閉じる", + collapse: "コマンド出力を折りたたむ", + expand: "コマンド出力を展開する", + title: "コマンド出力", + }, + locale: { + controlLabel: "メッセージの翻訳先言語", + label: "翻訳先言語", + noTargetLocales: "翻訳先言語が設定されていません。", + sourceLabel: "原文言語", + }, + messagePanel: { + additionalSourceLocations: (count) => `他 ${count} 件のソース位置`, + chooseCopyContent: "コピーする内容を選択", + collapseNamespace: (path) => `${path} を折りたたむ`, + collapsePackage: (packageName) => `${packageName} を折りたたむ`, + copied: "コピーしました", + copyDescriptor: "メッセージ記述子をコピー", + copyFailed: "コピーできませんでした", + copyMessageId: "メッセージ ID をコピー", + copySource: "原文をコピー", + copySourceLocation: "ソース位置をコピー", + copyTranslation: "現在の翻訳をコピー", + couldNotSave: "保存できませんでした", + emptyCatalog: + "メッセージはまだありません。プロジェクトを抽出してカタログを作成してください。", + emptyPackage: "このパッケージにはメッセージがありません。", + expandNamespace: (path) => `${path} を展開`, + expandPackage: (packageName) => `${packageName} を展開`, + loading: (locale) => `${locale} を読み込み中…`, + messageCount: (count) => `${count} 件`, + messageEditor: "メッセージエディター", + messageNavigation: "メッセージナビゲーション", + missingCount: (count) => `未翻訳 ${count} 件`, + missingOnly: "未翻訳のみ", + noMatches: "現在のフィルターに一致するメッセージはありません。", + noMissingMessages: "すべてのメッセージが翻訳済みです。", + noSearchResults: "検索に一致するメッセージはありません。", + reset: "リセット", + retry: "再試行", + save: "保存", + saved: "保存済み", + saving: "保存中…", + search: "メッセージを検索", + selectMessage: "翻訳を表示・編集するメッセージを選択してください。", + source: "原文", + sourceLocations: "ソース位置", + translation: "翻訳", + translationFor: (id, locale) => `${id} の ${locale} 翻訳`, + unknownSourceLocation: "ソース位置不明", + }, + settings: { + auto: "自動", + autoTheme: "自動テーマ", + center: "中央", + dark: "ダーク", + darkTheme: "ダークテーマ", + interfaceLanguage: "表示言語", + interfaceLanguageControl: "I18n Devtool の表示言語", + label: "I18n Devtool の設定", + left: "左", + light: "ライト", + lightTheme: "ライトテーマ", + open: "I18n Devtool の設定を開く", + panelPosition: "パネル位置", + panelPositionControl: "I18n Devtool のパネル位置", + right: "右", + theme: "テーマ", + themeControl: "I18n Devtool のテーマ", + }, + trigger: { + close: "I18n Devtool を閉じる", + open: "I18n Devtool を開く", + }, + }, + ko: { + actions: { + compile: "컴파일", + compileCompleted: "컴파일이 완료되었습니다.", + compileFailed: "컴파일에 실패했습니다.", + compiling: "컴파일 중…", + enterFullscreen: "전체 화면으로 전환", + exitFullscreen: "전체 화면 종료", + extract: "추출", + extractCompleted: "추출이 완료되었습니다.", + extractFailed: "추출에 실패했습니다.", + extracting: "추출 중…", + label: "작업", + }, + commandOutput: { + close: "명령 출력을 닫기", + collapse: "명령 출력을 접기", + expand: "명령 출력을 펼치기", + title: "명령 출력", + }, + locale: { + controlLabel: "메시지 대상 언어", + label: "대상 언어", + noTargetLocales: "대상 언어가 설정되지 않았습니다.", + sourceLabel: "원본 언어", + }, + messagePanel: { + additionalSourceLocations: (count) => `추가 소스 위치 ${count}개`, + chooseCopyContent: "복사할 내용 선택", + collapseNamespace: (path) => `${path} 접기`, + collapsePackage: (packageName) => `${packageName} 접기`, + copied: "복사됨", + copyDescriptor: "메시지 설명자 복사", + copyFailed: "복사하지 못했습니다", + copyMessageId: "메시지 ID 복사", + copySource: "원문 복사", + copySourceLocation: "소스 위치 복사", + copyTranslation: "현재 번역 복사", + couldNotSave: "저장하지 못했습니다", + emptyCatalog: + "아직 사용할 수 있는 메시지가 없습니다. 프로젝트에서 메시지를 추출해 카탈로그를 채우세요.", + emptyPackage: "이 패키지에는 메시지가 없습니다.", + expandNamespace: (path) => `${path} 펼치기`, + expandPackage: (packageName) => `${packageName} 펼치기`, + loading: (locale) => `${locale} 불러오는 중…`, + messageCount: (count) => `${count}개 메시지`, + messageEditor: "메시지 편집기", + messageNavigation: "메시지 탐색", + missingCount: (count) => `${count}개 누락`, + missingOnly: "누락된 항목만", + noMatches: "현재 필터와 일치하는 메시지가 없습니다.", + noMissingMessages: "모든 메시지가 번역되었습니다.", + noSearchResults: "검색어와 일치하는 메시지가 없습니다.", + reset: "재설정", + retry: "다시 시도", + save: "저장", + saved: "저장됨", + saving: "저장 중…", + search: "메시지 검색", + selectMessage: "번역을 확인하고 편집할 메시지를 선택하세요.", + source: "원문", + sourceLocations: "소스 위치", + translation: "번역", + translationFor: (id, locale) => `${id}의 ${locale} 번역`, + unknownSourceLocation: "알 수 없는 소스 위치", + }, + settings: { + auto: "자동", + autoTheme: "자동 테마", + center: "가운데", + dark: "다크", + darkTheme: "다크 테마", + interfaceLanguage: "인터페이스 언어", + interfaceLanguageControl: "I18n Devtool 인터페이스 언어", + label: "I18n Devtool 설정", + left: "왼쪽", + light: "라이트", + lightTheme: "라이트 테마", + open: "I18n Devtool 설정 열기", + panelPosition: "패널 위치", + panelPositionControl: "I18n Devtool 패널 위치", + right: "오른쪽", + theme: "테마", + themeControl: "I18n Devtool 테마", + }, + trigger: { + close: "I18n Devtool 닫기", + open: "I18n Devtool 열기", + }, + }, + "zh-Hans": { + actions: { + compile: "编译", + compileCompleted: "编译完成。", + compileFailed: "编译失败。", + compiling: "正在编译…", + enterFullscreen: "进入全屏", + exitFullscreen: "退出全屏", + extract: "提取", + extractCompleted: "提取完成。", + extractFailed: "提取失败。", + extracting: "正在提取…", + label: "操作", + }, + commandOutput: { + close: "关闭命令输出", + collapse: "收起命令输出", + expand: "展开命令输出", + title: "命令输出", + }, + locale: { + controlLabel: "消息目标语言", + label: "目标语言", + noTargetLocales: "尚未配置目标语言。", + sourceLabel: "源语言", + }, + messagePanel: { + additionalSourceLocations: (count) => `另外 ${count} 个来源位置`, + chooseCopyContent: "选择要复制的内容", + collapseNamespace: (path) => `折叠 ${path}`, + collapsePackage: (packageName) => `折叠 ${packageName}`, + copied: "已复制", + copyDescriptor: "复制 Message Descriptor", + copyFailed: "复制失败", + copyMessageId: "复制 Message ID", + copySource: "复制原文", + copySourceLocation: "复制来源位置", + copyTranslation: "复制当前译文", + couldNotSave: "保存失败", + emptyCatalog: "尚无可用消息。请先提取项目消息以生成目录。", + emptyPackage: "这个包中暂无消息。", + expandNamespace: (path) => `展开 ${path}`, + expandPackage: (packageName) => `展开 ${packageName}`, + loading: (locale) => `正在加载 ${locale}…`, + messageCount: (count) => `共 ${count} 条`, + messageEditor: "消息编辑器", + messageNavigation: "消息导航", + missingCount: (count) => `缺失 ${count} 条`, + missingOnly: "仅显示缺失项", + noMatches: "没有符合当前筛选条件的消息。", + noMissingMessages: "所有消息均已完成翻译。", + noSearchResults: "没有与搜索内容匹配的消息。", + reset: "重置", + retry: "重试", + save: "保存", + saved: "已保存", + saving: "正在保存…", + search: "搜索消息", + selectMessage: "请选择一条消息以查看和编辑译文。", + source: "原文", + sourceLocations: "来源位置", + translation: "译文", + translationFor: (id, locale) => `${id} 的 ${locale} 翻译`, + unknownSourceLocation: "未知来源位置", + }, + settings: { + auto: "自动", + autoTheme: "自动主题", + center: "居中", + dark: "深色", + darkTheme: "深色主题", + interfaceLanguage: "界面语言", + interfaceLanguageControl: "I18n 开发工具界面语言", + label: "I18n 开发工具设置", + left: "左侧", + light: "浅色", + lightTheme: "浅色主题", + open: "打开 I18n 开发工具设置", + panelPosition: "面板位置", + panelPositionControl: "I18n 开发工具面板位置", + right: "右侧", + theme: "主题", + themeControl: "I18n 开发工具主题", + }, + trigger: { + close: "关闭 I18n 开发工具", + open: "打开 I18n 开发工具", + }, + }, + "zh-Hant": { + actions: { + compile: "編譯", + compileCompleted: "編譯完成。", + compileFailed: "編譯失敗。", + compiling: "正在編譯…", + enterFullscreen: "進入全螢幕", + exitFullscreen: "退出全螢幕", + extract: "擷取", + extractCompleted: "擷取完成。", + extractFailed: "擷取失敗。", + extracting: "正在擷取…", + label: "操作", + }, + commandOutput: { + close: "關閉命令輸出", + collapse: "收合命令輸出", + expand: "展開命令輸出", + title: "命令輸出", + }, + locale: { + controlLabel: "訊息目標語言", + label: "目標語言", + noTargetLocales: "尚未設定目標語言。", + sourceLabel: "來源語言", + }, + messagePanel: { + additionalSourceLocations: (count) => `另外 ${count} 個來源位置`, + chooseCopyContent: "選擇要複製的內容", + collapseNamespace: (path) => `收合 ${path}`, + collapsePackage: (packageName) => `收合 ${packageName}`, + copied: "已複製", + copyDescriptor: "複製 Message Descriptor", + copyFailed: "複製失敗", + copyMessageId: "複製 Message ID", + copySource: "複製原文", + copySourceLocation: "複製來源位置", + copyTranslation: "複製目前譯文", + couldNotSave: "儲存失敗", + emptyCatalog: "尚無可用訊息。請先擷取專案訊息以產生目錄。", + emptyPackage: "這個套件中暫無訊息。", + expandNamespace: (path) => `展開 ${path}`, + expandPackage: (packageName) => `展開 ${packageName}`, + loading: (locale) => `正在載入 ${locale}…`, + messageCount: (count) => `共 ${count} 筆`, + messageEditor: "訊息編輯器", + messageNavigation: "訊息導覽", + missingCount: (count) => `缺漏 ${count} 筆`, + missingOnly: "僅顯示缺漏項目", + noMatches: "沒有符合目前篩選條件的訊息。", + noMissingMessages: "所有訊息皆已完成翻譯。", + noSearchResults: "沒有符合搜尋內容的訊息。", + reset: "重設", + retry: "重試", + save: "儲存", + saved: "已儲存", + saving: "正在儲存…", + search: "搜尋訊息", + selectMessage: "請選擇一則訊息以檢視和編輯譯文。", + source: "原文", + sourceLocations: "來源位置", + translation: "譯文", + translationFor: (id, locale) => `${id} 的 ${locale} 翻譯`, + unknownSourceLocation: "未知來源位置", + }, + settings: { + auto: "自動", + autoTheme: "自動主題", + center: "置中", + dark: "深色", + darkTheme: "深色主題", + interfaceLanguage: "介面語言", + interfaceLanguageControl: "I18n 開發工具介面語言", + label: "I18n 開發工具設定", + left: "左側", + light: "淺色", + lightTheme: "淺色主題", + open: "開啟 I18n 開發工具設定", + panelPosition: "面板位置", + panelPositionControl: "I18n 開發工具面板位置", + right: "右側", + theme: "主題", + themeControl: "I18n 開發工具主題", + }, + trigger: { + close: "關閉 I18n 開發工具", + open: "開啟 I18n 開發工具", + }, + }, +} + +const defaultContextValue: { + locale: I18nDevtoolLocale + messages: DevtoolMessages +} = { + locale: "en", + messages: messagesByLocale.en, +} + +const DevtoolLocalizationContext = React.createContext(defaultContextValue) + +function matchDevtoolLocale(locale: string): I18nDevtoolLocale | undefined { + const normalizedLocale = locale.trim().replaceAll("_", "-").toLowerCase() + + if (!normalizedLocale) { + return undefined + } + + if ( + normalizedLocale === "zh-hant" || + normalizedLocale.startsWith("zh-hant-") + ) { + return "zh-Hant" + } + + if ( + normalizedLocale === "zh-tw" || + normalizedLocale.startsWith("zh-tw-") || + normalizedLocale === "zh-hk" || + normalizedLocale.startsWith("zh-hk-") || + normalizedLocale === "zh-mo" || + normalizedLocale.startsWith("zh-mo-") + ) { + return "zh-Hant" + } + + if (normalizedLocale === "zh" || normalizedLocale.startsWith("zh-")) { + return "zh-Hans" + } + + const language = normalizedLocale.split("-")[0] + + return I18N_DEVTOOL_LOCALES.find((candidate) => candidate === language) +} + +function getNavigatorLocales() { + if (typeof navigator === "undefined") { + return [] + } + + if (navigator.languages.length > 0) { + return navigator.languages + } + + return navigator.language ? [navigator.language] : [] +} + +export function resolveI18nDevtoolLocale( + locale?: string, + preferredLocales: readonly string[] = getNavigatorLocales() +): I18nDevtoolLocale { + const candidates = locale ? [locale] : preferredLocales + + for (const candidate of candidates) { + const matchedLocale = matchDevtoolLocale(candidate) + + if (matchedLocale) { + return matchedLocale + } + } + + return "en" +} + +export function getI18nDevtoolMessages(locale: I18nDevtoolLocale) { + return messagesByLocale[locale] +} + +export function DevtoolLocalizationProvider({ + children, + locale, +}: { + children: React.ReactNode + locale: I18nDevtoolLocale +}) { + const value = React.useMemo( + () => ({ + locale, + messages: messagesByLocale[locale], + }), + [locale] + ) + + return ( + + {children} + + ) +} + +export function useDevtoolLocalization() { + return React.useContext(DevtoolLocalizationContext) +} diff --git a/packages/i18n/src/devtool/devtool-preferences.ts b/packages/i18n/src/devtool/devtool-preferences.ts new file mode 100644 index 0000000..bd9e3f3 --- /dev/null +++ b/packages/i18n/src/devtool/devtool-preferences.ts @@ -0,0 +1,124 @@ +import * as React from "react" + +import { + I18N_DEVTOOL_LOCALES, + type I18nDevtoolLocale, +} from "./devtool-localization" + +export type I18nDevtoolPanelPlacement = "center" | "left" | "right" +export type I18nDevtoolTheme = "auto" | "dark" | "light" + +interface DevtoolPreferences { + interfaceLocale: I18nDevtoolLocale + panelPlacement: I18nDevtoolPanelPlacement + theme: I18nDevtoolTheme + version: 1 +} + +interface UseDevtoolPreferencesOptions { + defaultInterfaceLocale: I18nDevtoolLocale + defaultPanelPlacement: I18nDevtoolPanelPlacement + defaultTheme: I18nDevtoolTheme +} + +const STORAGE_KEY = "i18n-devtool:preferences" + +function isPanelPlacement(value: unknown): value is I18nDevtoolPanelPlacement { + return value === "center" || value === "left" || value === "right" +} + +function isTheme(value: unknown): value is I18nDevtoolTheme { + return value === "auto" || value === "dark" || value === "light" +} + +function isInterfaceLocale(value: unknown): value is I18nDevtoolLocale { + return ( + typeof value === "string" && + I18N_DEVTOOL_LOCALES.includes(value as I18nDevtoolLocale) + ) +} + +function readStoredPreferences() { + if (typeof window === "undefined") { + return null + } + + try { + const parsed = JSON.parse( + window.localStorage.getItem(STORAGE_KEY) ?? "" + ) as Partial + + if ( + parsed.version !== 1 || + !isPanelPlacement(parsed.panelPlacement) || + !isTheme(parsed.theme) + ) { + return null + } + + return { + interfaceLocale: isInterfaceLocale(parsed.interfaceLocale) + ? parsed.interfaceLocale + : undefined, + panelPlacement: parsed.panelPlacement, + theme: parsed.theme, + version: 1 as const, + } + } catch { + return null + } +} + +export function useDevtoolPreferences({ + defaultInterfaceLocale, + defaultPanelPlacement, + defaultTheme, +}: UseDevtoolPreferencesOptions) { + const [preferences, setPreferences] = React.useState( + () => { + const storedPreferences = readStoredPreferences() + + return { + interfaceLocale: + storedPreferences?.interfaceLocale ?? defaultInterfaceLocale, + panelPlacement: + storedPreferences?.panelPlacement ?? defaultPanelPlacement, + theme: storedPreferences?.theme ?? defaultTheme, + version: 1, + } + } + ) + + React.useEffect(() => { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)) + } catch { + // Storage can be unavailable in privacy-restricted browser contexts. + } + }, [preferences]) + + const setPanelPlacement = React.useCallback( + (panelPlacement: I18nDevtoolPanelPlacement) => { + setPreferences((current) => ({ ...current, panelPlacement })) + }, + [] + ) + const setTheme = React.useCallback((theme: I18nDevtoolTheme) => { + setPreferences((current) => ({ ...current, theme })) + }, []) + const setInterfaceLocale = React.useCallback( + (interfaceLocale: I18nDevtoolLocale) => { + setPreferences((current) => ({ ...current, interfaceLocale })) + }, + [] + ) + + return { + interfaceLocale: preferences.interfaceLocale, + panelPlacement: preferences.panelPlacement, + setInterfaceLocale, + setPanelPlacement, + setTheme, + theme: preferences.theme, + } +} diff --git a/packages/i18n/src/devtool/devtool-select.tsx b/packages/i18n/src/devtool/devtool-select.tsx new file mode 100644 index 0000000..84822e0 --- /dev/null +++ b/packages/i18n/src/devtool/devtool-select.tsx @@ -0,0 +1,38 @@ +import type * as React from "react" + +export interface DevtoolSelectOption { + disabled?: boolean + label: string + value: string +} + +export interface DevtoolSelectProps extends Omit< + React.ComponentProps<"select">, + "children" +> { + options: readonly DevtoolSelectOption[] +} + +export function DevtoolSelect({ + className, + options, + ...props +}: DevtoolSelectProps) { + const resolvedClassName = ["i18n-devtool__select", className] + .filter(Boolean) + .join(" ") + + return ( + + ) +} diff --git a/packages/i18n/src/devtool/devtool-settings.tsx b/packages/i18n/src/devtool/devtool-settings.tsx new file mode 100644 index 0000000..f3711e5 --- /dev/null +++ b/packages/i18n/src/devtool/devtool-settings.tsx @@ -0,0 +1,159 @@ +import * as React from "react" +import { + AlignCenterIcon, + AlignLeftIcon, + AlignRightIcon, + SettingsIcon, +} from "lucide-react" + +import type { + I18nDevtoolPanelPlacement, + I18nDevtoolTheme, +} from "./devtool-preferences" +import { + I18N_DEVTOOL_LOCALES, + type I18nDevtoolLocale, + useDevtoolLocalization, +} from "./devtool-localization" +import { DevtoolSelect } from "./devtool-select" +import { DevtoolThemeSwitcher } from "./devtool-theme-switcher" + +export interface DevtoolSettingsProps { + interfaceLocale: I18nDevtoolLocale + onInterfaceLocaleChange: (locale: I18nDevtoolLocale) => void + onPanelPlacementChange: (placement: I18nDevtoolPanelPlacement) => void + onThemeChange: (theme: I18nDevtoolTheme) => void + panelPlacement: I18nDevtoolPanelPlacement + showPanelPlacement?: boolean + theme: I18nDevtoolTheme +} + +const panelPlacements = [ + { icon: AlignLeftIcon, label: "left", value: "left" }, + { icon: AlignCenterIcon, label: "center", value: "center" }, + { icon: AlignRightIcon, label: "right", value: "right" }, +] as const + +const interfaceLocaleLabels: Record = { + de: "Deutsch", + en: "English", + es: "Español", + fr: "Français", + ja: "日本語", + ko: "한국어", + "zh-Hans": "简体中文", + "zh-Hant": "繁體中文", +} + +export function DevtoolSettings({ + interfaceLocale, + onInterfaceLocaleChange, + onPanelPlacementChange, + onThemeChange, + panelPlacement, + showPanelPlacement = true, + theme, +}: DevtoolSettingsProps) { + const [isOpen, setOpen] = React.useState(false) + const rootRef = React.useRef(null) + const { messages } = useDevtoolLocalization() + + React.useEffect(() => { + if (!isOpen) { + return + } + + const handlePointerDown = (event: PointerEvent) => { + if ( + event.target instanceof Node && + !rootRef.current?.contains(event.target) + ) { + setOpen(false) + } + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") { + return + } + + event.stopPropagation() + setOpen(false) + } + + document.addEventListener("pointerdown", handlePointerDown, true) + document.addEventListener("keydown", handleKeyDown, true) + + return () => { + document.removeEventListener("pointerdown", handlePointerDown, true) + document.removeEventListener("keydown", handleKeyDown, true) + } + }, [isOpen]) + + return ( +
+ + + {isOpen && ( +
+ + +
+ {messages.settings.theme} + +
+ + {showPanelPlacement && ( +
+ {messages.settings.panelPosition} +
+ {panelPlacements.map( + ({ icon: PlacementIcon, label, value }) => ( + + ) + )} +
+
+ )} +
+ )} +
+ ) +} diff --git a/packages/i18n/src/devtool/devtool-theme-switcher.tsx b/packages/i18n/src/devtool/devtool-theme-switcher.tsx new file mode 100644 index 0000000..7b01039 --- /dev/null +++ b/packages/i18n/src/devtool/devtool-theme-switcher.tsx @@ -0,0 +1,58 @@ +import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react" + +import type { I18nDevtoolTheme } from "./devtool-preferences" +import { useDevtoolLocalization } from "./devtool-localization" + +export interface DevtoolThemeSwitcherProps { + onThemeChange: (theme: I18nDevtoolTheme) => void + theme: I18nDevtoolTheme +} + +const themes = [ + { + accessibleLabel: "autoTheme", + icon: MonitorIcon, + label: "auto", + value: "auto", + }, + { + accessibleLabel: "lightTheme", + icon: SunIcon, + label: "light", + value: "light", + }, + { + accessibleLabel: "darkTheme", + icon: MoonIcon, + label: "dark", + value: "dark", + }, +] as const + +export function DevtoolThemeSwitcher({ + onThemeChange, + theme, +}: DevtoolThemeSwitcherProps) { + const { messages } = useDevtoolLocalization() + + return ( +
+ {themes.map(({ accessibleLabel, icon: ThemeIcon, label, value }) => ( + + ))} +
+ ) +} diff --git a/packages/i18n/src/devtool/http-message-repository.ts b/packages/i18n/src/devtool/http-message-repository.ts index 5c4b1a6..ea67b10 100644 --- a/packages/i18n/src/devtool/http-message-repository.ts +++ b/packages/i18n/src/devtool/http-message-repository.ts @@ -1,5 +1,6 @@ import type { DevtoolMessage, + DevtoolMessagePackage, MessageRepository, UpdateMessageInput, } from "./types" @@ -38,6 +39,11 @@ export function createHttpMessageRepository( return readResponse(response) }, + async getPackages() { + const response = await fetch(`${normalizedBaseUrl}/packages`) + + return readResponse(response) + }, async updateMessage(input: UpdateMessageInput) { const response = await fetch(`${normalizedBaseUrl}/messages`, { body: JSON.stringify(input), diff --git a/packages/i18n/src/devtool/i18n-devtool-trigger.tsx b/packages/i18n/src/devtool/i18n-devtool-trigger.tsx new file mode 100644 index 0000000..de655cc --- /dev/null +++ b/packages/i18n/src/devtool/i18n-devtool-trigger.tsx @@ -0,0 +1,58 @@ +import { LanguagesIcon } from "lucide-react" + +import { useDevtoolLocalization } from "./devtool-localization" +import { useFloatingTrigger } from "./use-floating-trigger" + +export interface I18nDevtoolTriggerProps { + controls?: string + open?: boolean + onOpen: () => void + storageKey?: string +} + +export function I18nDevtoolTrigger({ + controls = "i18n-devtool-panel", + open = false, + onOpen, + storageKey, +}: I18nDevtoolTriggerProps) { + const floatingTrigger = useFloatingTrigger({ storageKey }) + const { messages } = useDevtoolLocalization() + + return ( + + ) +} diff --git a/packages/i18n/src/devtool/i18n-devtool.css b/packages/i18n/src/devtool/i18n-devtool.css index 4991a37..c41f2f8 100644 --- a/packages/i18n/src/devtool/i18n-devtool.css +++ b/packages/i18n/src/devtool/i18n-devtool.css @@ -5,6 +5,8 @@ --i18n-devtool-muted: #71717a; --i18n-devtool-panel: #fff; --i18n-devtool-primary: #2563eb; + --i18n-devtool-control-hover: rgb(15 23 42 / 6%); + --i18n-devtool-control-pressed: rgb(15 23 42 / 11%); position: fixed; z-index: 2147483647; color: var(--i18n-devtool-foreground); @@ -26,6 +28,8 @@ --i18n-devtool-muted: #a1a1aa; --i18n-devtool-panel: #181a20; --i18n-devtool-primary: #60a5fa; + --i18n-devtool-control-hover: rgb(255 255 255 / 8%); + --i18n-devtool-control-pressed: rgb(255 255 255 / 14%); color-scheme: dark; } @@ -46,10 +50,13 @@ right: 1rem; bottom: 1rem; display: inline-flex; - height: 2.75rem; + width: max-content; + height: 3rem; align-items: center; - gap: 0.45rem; - padding: 0 0.85rem; + gap: 0.5rem; + justify-content: center; + overflow: visible; + padding: 0 0.5rem; border: 1px solid var(--i18n-devtool-border); border-radius: 999px; background: var(--i18n-devtool-panel); @@ -57,28 +64,169 @@ cursor: pointer; font-size: 0.8125rem; font-weight: 650; + touch-action: none; + transition: + gap 140ms ease, + border-radius 140ms ease, + background-color 120ms ease, + box-shadow 140ms ease, + transform 160ms ease; + user-select: none; } -.i18n-devtool__trigger span:first-child { +.i18n-devtool__trigger::before { + position: absolute; + display: none; + content: ""; +} + +.i18n-devtool__trigger[hidden] { + display: none; +} + +.i18n-devtool__trigger:focus-visible { + outline: 2px solid var(--i18n-devtool-primary); + outline-offset: 2px; +} + +.i18n-devtool__trigger[data-dragging] { + cursor: grabbing; + box-shadow: 0 16px 36px rgb(0 0 0 / 24%); +} + +.i18n-devtool__trigger:hover { + background: color-mix( + in srgb, + var(--i18n-devtool-primary) 7%, + var(--i18n-devtool-panel) + ); +} + +.i18n-devtool__trigger:active { + background: color-mix( + in srgb, + var(--i18n-devtool-primary) 12%, + var(--i18n-devtool-panel) + ); +} + +.i18n-devtool__trigger[data-collapsed] { + gap: 0; +} + +.i18n-devtool__trigger[data-dock="left"] { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.i18n-devtool__trigger[data-dock="right"] { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.i18n-devtool__trigger[data-dock="top"] { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.i18n-devtool__trigger[data-dock="bottom"] { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.i18n-devtool__trigger[data-dock]:not([data-collapsed]) { + border-radius: 999px; +} + +.i18n-devtool__trigger[data-dock="left"]:not([data-collapsed]) { + transform: translateX(0.5rem); +} + +.i18n-devtool__trigger[data-dock="right"]:not([data-collapsed]) { + transform: translateX(-0.5rem); +} + +.i18n-devtool__trigger[data-dock="top"]:not([data-collapsed]) { + transform: translateY(0.5rem); +} + +.i18n-devtool__trigger[data-dock="bottom"]:not([data-collapsed]) { + transform: translateY(-0.5rem); +} + +.i18n-devtool__trigger[data-dock="left"]:not([data-collapsed])::before { + inset-block: 0; + right: 100%; + display: block; + width: 0.5rem; +} + +.i18n-devtool__trigger[data-dock="right"]:not([data-collapsed])::before { + inset-block: 0; + left: 100%; + display: block; + width: 0.5rem; +} + +.i18n-devtool__trigger[data-dock="top"]:not([data-collapsed])::before { + bottom: 100%; + inset-inline: 0; + display: block; + height: 0.5rem; +} + +.i18n-devtool__trigger[data-dock="bottom"]:not([data-collapsed])::before { + top: 100%; + inset-inline: 0; + display: block; + height: 0.5rem; +} + +.i18n-devtool__trigger-icon { display: grid; - width: 1.5rem; - height: 1.5rem; + width: 2rem; + height: 2rem; + flex: 0 0 2rem; place-items: center; - border-radius: 0.4rem; + border-radius: 50%; background: var(--i18n-devtool-primary); color: white; - font-size: 0.75rem; +} + +.i18n-devtool__trigger-icon svg { + display: block; + width: 1.25rem; + height: 1.25rem; + stroke-width: 2.25; +} + +.i18n-devtool__trigger-label { + width: 2.5rem; + max-width: 2.5rem; + overflow: hidden; + opacity: 1; + transform: translateX(0); + transition: + max-width 140ms ease, + opacity 100ms ease, + transform 140ms ease; + white-space: nowrap; +} + +.i18n-devtool__trigger[data-collapsed] .i18n-devtool__trigger-label { + max-width: 0; + opacity: 0; + transform: translateX(-0.35rem); } .i18n-devtool__panel { position: fixed; top: 1rem; - right: 1rem; bottom: 1rem; - display: grid; - width: min(54rem, calc(100vw - 2rem)); + display: flex; + width: min(80rem, calc(100vw - 2rem)); overflow: hidden; - grid-template-rows: auto auto minmax(0, 1fr); + flex-direction: column; border: 1px solid var(--i18n-devtool-border); border-radius: 1rem; outline: none; @@ -86,6 +234,22 @@ box-shadow: 0 24px 70px rgb(0 0 0 / 28%); } +.i18n-devtool__panel[data-placement="left"] { + right: auto; + left: 1rem; +} + +.i18n-devtool__panel[data-placement="center"] { + right: auto; + left: 50%; + transform: translateX(-50%); +} + +.i18n-devtool__panel[data-placement="right"] { + right: 1rem; + left: auto; +} + .i18n-devtool[data-mode="standalone"] { inset: 0; } @@ -96,9 +260,21 @@ border: 0; border-radius: 0; box-shadow: none; + transform: none; +} + +.i18n-devtool__panel[data-fullscreen] { + inset: 0; + width: 100vw; + border: 0; + border-radius: 0; + box-shadow: none; + transform: none; } .i18n-devtool__header { + position: relative; + z-index: 3; display: flex; align-items: center; justify-content: space-between; @@ -129,58 +305,347 @@ .i18n-devtool__controls { display: flex; align-items: end; - gap: 0.4rem; + gap: 1rem; } -.i18n-devtool__controls label { +.i18n-devtool__locale-field, +.i18n-devtool__source-locale-field, +.i18n-devtool__action-field { display: grid; gap: 0.2rem; } -.i18n-devtool__controls label span { +.i18n-devtool__action-field { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.i18n-devtool__locale-field > span, +.i18n-devtool__source-locale-field > span, +.i18n-devtool__action-field > legend { + padding: 0; color: var(--i18n-devtool-muted); font-size: 0.625rem; font-weight: 650; text-transform: uppercase; } -.i18n-devtool__controls select, -.i18n-devtool__controls button { +.i18n-devtool__source-locale { + display: inline-flex; + min-width: 7rem; + height: 2.25rem; + align-items: center; + padding: 0 0.7rem; + border: 1px solid var(--i18n-devtool-border); + border-radius: 0.55rem; + background: color-mix( + in srgb, + var(--i18n-devtool-panel) 82%, + var(--i18n-devtool-muted) 18% + ); + color: var(--i18n-devtool-muted); + white-space: nowrap; +} + +.i18n-devtool__action-controls { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.i18n-devtool__action-button { + display: grid; + place-items: center; +} + +.i18n-devtool__action-button > span { + grid-area: 1 / 1; +} + +.i18n-devtool__action-label-measure { + visibility: hidden; +} + +.i18n-devtool__select, +.i18n-devtool__action-controls > button, +.i18n-devtool__settings-trigger { height: 2.25rem; padding: 0 0.7rem; border: 1px solid var(--i18n-devtool-border); border-radius: 0.55rem; + outline: none; background: var(--i18n-devtool-panel); + transition: + background-color 120ms ease, + border-color 120ms ease, + box-shadow 120ms ease, + transform 80ms ease; } -.i18n-devtool__controls button { +.i18n-devtool__select { + color: var(--i18n-devtool-foreground); cursor: pointer; } -.i18n-devtool__controls button:disabled { +.i18n-devtool__locale-select { + min-width: 9rem; +} + +.i18n-devtool__action-controls > button, +.i18n-devtool__settings-trigger { + cursor: pointer; +} + +.i18n-devtool__select:hover, +.i18n-devtool__action-controls > button:hover:not(:disabled), +.i18n-devtool__settings-trigger:hover { + background: var(--i18n-devtool-control-hover); +} + +.i18n-devtool__select:focus-visible, +.i18n-devtool__action-controls > button:focus-visible, +.i18n-devtool__settings-trigger:focus-visible { + border-color: var(--i18n-devtool-primary); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--i18n-devtool-primary) 18%, transparent); +} + +.i18n-devtool__action-controls > button:active:not(:disabled), +.i18n-devtool__settings-trigger:active { + background: var(--i18n-devtool-control-pressed); + transform: translateY(1px); +} + +.i18n-devtool__action-controls > button:disabled { cursor: default; opacity: 0.45; } -.i18n-devtool__controls .i18n-devtool__close { +.i18n-devtool__controls .i18n-devtool__close, +.i18n-devtool__controls .i18n-devtool__fullscreen { + display: inline-grid; width: 2.25rem; + place-items: center; padding: 0; - font-size: 1.25rem; +} + +.i18n-devtool__close svg, +.i18n-devtool__fullscreen svg { + width: 1rem; + height: 1rem; +} + +.i18n-devtool__fullscreen[aria-pressed="true"] { + border-color: color-mix( + in srgb, + var(--i18n-devtool-primary) 45%, + var(--i18n-devtool-border) + ); + background: color-mix( + in srgb, + var(--i18n-devtool-primary) 12%, + var(--i18n-devtool-panel) + ); + color: var(--i18n-devtool-primary); +} + +.i18n-devtool__theme-switcher { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.25rem; + padding: 0.25rem; + border: 1px solid var(--i18n-devtool-border); + border-radius: 0.625rem; + background: var(--i18n-devtool-panel); +} + +.i18n-devtool__theme-switcher button { + display: inline-flex; + min-width: 0; + height: 2.25rem; + align-items: center; + justify-content: center; + gap: 0.375rem; + padding: 0 0.5rem; + border: 0; + border-radius: 0.375rem; + outline: none; + background: transparent; + color: var(--i18n-devtool-muted); + cursor: pointer; + transition: + color 120ms ease, + background-color 120ms ease, + transform 80ms ease; +} + +.i18n-devtool__theme-switcher button:hover { + background: var(--i18n-devtool-control-hover); + color: var(--i18n-devtool-foreground); +} + +.i18n-devtool__theme-switcher button:active { + transform: scale(0.94); +} + +.i18n-devtool__theme-switcher button:focus-visible { + box-shadow: 0 0 0 2px var(--i18n-devtool-primary); +} + +.i18n-devtool__theme-switcher button[aria-pressed="true"] { + background: color-mix( + in srgb, + var(--i18n-devtool-primary) 14%, + var(--i18n-devtool-panel) + ); + color: var(--i18n-devtool-primary); +} + +.i18n-devtool__theme-switcher svg { + width: 1rem; + height: 1rem; + flex: 0 0 1rem; +} + +.i18n-devtool__theme-switcher span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.i18n-devtool__settings { + position: relative; + align-self: end; +} + +.i18n-devtool__settings-trigger { + display: inline-grid; + width: 2.25rem; + place-items: center; + padding: 0; +} + +.i18n-devtool__settings-trigger svg { + width: 1rem; + height: 1rem; +} + +.i18n-devtool__settings-trigger[aria-expanded="true"] { + border-color: color-mix( + in srgb, + var(--i18n-devtool-primary) 45%, + var(--i18n-devtool-border) + ); + background: color-mix( + in srgb, + var(--i18n-devtool-primary) 12%, + var(--i18n-devtool-panel) + ); + color: var(--i18n-devtool-primary); +} + +.i18n-devtool__settings-panel { + position: absolute; + z-index: 5; + top: calc(100% + 0.5rem); + right: 0; + display: grid; + width: 20rem; + max-width: calc(100vw - 3rem); + gap: 1rem; + padding: 1rem; + border: 1px solid var(--i18n-devtool-border); + border-radius: 0.875rem; + background: var(--i18n-devtool-panel); + box-shadow: 0 18px 48px rgb(0 0 0 / 22%); +} + +.i18n-devtool__settings-field { + display: grid; + gap: 0.45rem; +} + +.i18n-devtool__settings-field > span { + color: var(--i18n-devtool-muted); + font-size: 0.675rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.i18n-devtool__settings-panel .i18n-devtool__select { + width: 100%; + height: 2.75rem; + border-radius: 0.625rem; +} + +.i18n-devtool__settings-options { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.25rem; + padding: 0.25rem; + border: 1px solid var(--i18n-devtool-border); + border-radius: 0.625rem; + background: var(--i18n-devtool-panel); +} + +.i18n-devtool__settings-options button { + display: inline-flex; + min-width: 0; + height: 2.25rem; + align-items: center; + justify-content: center; + gap: 0.375rem; + padding: 0 0.5rem; + border: 0; + border-radius: 0.375rem; + outline: none; + background: transparent; + color: var(--i18n-devtool-muted); + cursor: pointer; + transition: + color 120ms ease, + background-color 120ms ease, + transform 80ms ease; +} + +.i18n-devtool__settings-options button:hover { + background: var(--i18n-devtool-control-hover); + color: var(--i18n-devtool-foreground); +} + +.i18n-devtool__settings-options button:active { + transform: scale(0.97); +} + +.i18n-devtool__settings-options button:focus-visible { + box-shadow: 0 0 0 2px var(--i18n-devtool-primary); +} + +.i18n-devtool__settings-options button[aria-pressed="true"] { + background: color-mix( + in srgb, + var(--i18n-devtool-primary) 14%, + var(--i18n-devtool-panel) + ); + color: var(--i18n-devtool-primary); +} + +.i18n-devtool__settings-options svg { + width: 1rem; + height: 1rem; + flex: 0 0 1rem; } .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] { @@ -188,15 +653,100 @@ color: #ef4444; } -.i18n-devtool__main { - min-height: 0; +.i18n-devtool__output-header { + display: flex; + min-height: 2.25rem; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.25rem 0.5rem 0.25rem 1rem; + color: var(--i18n-devtool-muted); + font-size: 0.7rem; + font-weight: 650; + text-transform: uppercase; +} + +.i18n-devtool__output-header > span, +.i18n-devtool__output-header > div { + display: inline-flex; + align-items: center; + gap: 0.375rem; +} + +.i18n-devtool__output-header button { + display: inline-grid; + width: 1.75rem; + height: 1.75rem; + place-items: center; + padding: 0; + border: 0; + border-radius: 0.375rem; + outline: none; + background: transparent; + color: inherit; + cursor: pointer; +} + +.i18n-devtool__output-header button:hover { + background: var(--i18n-devtool-control-hover); + color: var(--i18n-devtool-foreground); +} + +.i18n-devtool__output-header button:active { + background: var(--i18n-devtool-control-pressed); +} + +.i18n-devtool__output-header button:focus-visible { + box-shadow: 0 0 0 2px var(--i18n-devtool-primary); +} + +.i18n-devtool__output-header svg { + width: 0.875rem; + height: 0.875rem; +} + +.i18n-devtool__output pre { + max-height: 10rem; overflow: auto; + margin: 0; + padding: 0.65rem 1rem; + border-top: 1px solid var(--i18n-devtool-border); + font: + 0.72rem/1.5 ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + monospace; + white-space: pre-wrap; +} + +.i18n-devtool__main { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.i18n-devtool__empty { + display: grid; + min-height: 100%; + place-items: center; + padding: 2rem; + color: var(--i18n-devtool-muted); + text-align: center; } .i18n-devtool .i18n-message-panel { --i18n-border: var(--i18n-devtool-border); --i18n-muted: var(--i18n-devtool-muted); --i18n-surface: var(--i18n-devtool-panel); + height: 100%; +} + +.i18n-devtool .i18n-message-workspace { + height: 0; + min-height: 0; + overflow: hidden; } .i18n-devtool .i18n-message-panel__toolbar { @@ -209,15 +759,116 @@ .i18n-devtool .i18n-message-panel input[type="search"], .i18n-devtool .i18n-message-panel textarea, -.i18n-devtool .i18n-message-panel button { +.i18n-devtool .i18n-message-panel button:not(.i18n-message-copy__trigger) { background: var(--i18n-devtool-panel); } +.i18n-devtool .i18n-message-panel button.i18n-message-copy__trigger, +.i18n-devtool + .i18n-message-panel + button.i18n-message-copy__trigger:hover:not(:disabled), +.i18n-devtool + .i18n-message-panel + button.i18n-message-copy__trigger[aria-expanded="true"] { + background: transparent; +} + +.i18n-devtool + .i18n-message-panel + .i18n-message-copy__menu + > button:hover:not(:disabled), +.i18n-devtool + .i18n-message-panel + .i18n-message-copy__menu + > button:focus-visible { + background: var(--i18n-devtool-control-hover); +} + +.i18n-devtool .i18n-message-copy__feedback { + background: var(--i18n-devtool-panel); +} + +.i18n-devtool .i18n-message-panel .i18n-message-navigation button { + background: transparent; +} + +@supports (appearance: base-select) { + .i18n-devtool__select, + .i18n-devtool__select::picker(select) { + appearance: base-select; + } + + .i18n-devtool__select { + align-items: center; + gap: 0.5rem; + } + + .i18n-devtool__select::picker-icon { + width: 1rem; + height: 1rem; + flex: 0 0 1rem; + background: currentColor; + content: ""; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") + center / contain no-repeat; + transition: rotate 140ms ease; + } + + .i18n-devtool__select:open::picker-icon { + rotate: 180deg; + } + + .i18n-devtool__select::picker(select) { + margin-block-start: 0.25rem; + padding: 0.25rem; + border: 1px solid var(--i18n-devtool-border); + border-radius: 0.625rem; + background: var(--i18n-devtool-panel); + box-shadow: 0 12px 32px rgb(0 0 0 / 20%); + color: var(--i18n-devtool-foreground); + } + + .i18n-devtool__select option { + min-height: 2.25rem; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.625rem; + border: 0; + border-radius: 0.4rem; + background: transparent; + color: inherit; + } + + .i18n-devtool__select option:hover, + .i18n-devtool__select option:focus { + background: var(--i18n-devtool-control-hover); + } + + .i18n-devtool__select option:checked { + background: color-mix( + in srgb, + var(--i18n-devtool-primary) 12%, + var(--i18n-devtool-panel) + ); + color: var(--i18n-devtool-primary); + } + + .i18n-devtool__select option::checkmark { + width: 1rem; + height: 1rem; + background: currentColor; + content: ""; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E") + center / contain no-repeat; + } +} + @media (max-width: 42rem) { .i18n-devtool__panel { inset: 0; width: 100vw; border-radius: 0; + transform: none; } .i18n-devtool__header { @@ -229,8 +880,13 @@ flex-wrap: wrap; } - .i18n-devtool__controls label { + .i18n-devtool__locale-field, + .i18n-devtool__source-locale-field { min-width: 10rem; flex: 1; } + + .i18n-devtool__action-field { + flex: none; + } } diff --git a/packages/i18n/src/devtool/i18n-devtool.test.tsx b/packages/i18n/src/devtool/i18n-devtool.test.tsx index 5456fbb..8028bb5 100644 --- a/packages/i18n/src/devtool/i18n-devtool.test.tsx +++ b/packages/i18n/src/devtool/i18n-devtool.test.tsx @@ -1,17 +1,159 @@ // @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 { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" import { I18nProvider } from "../runtime" -import { I18nDevtool } from "./i18n-devtool" +import { I18nDevtool, type I18nDevtoolProps } from "./i18n-devtool" afterEach(() => { + cleanup() document.documentElement.classList.remove("ui:dark") + window.localStorage.clear() + vi.restoreAllMocks() + vi.unstubAllGlobals() }) +function renderDevtool(props?: I18nDevtoolProps) { + return render( + + + + ) +} + +function mockDevtoolApi(output = "Done") { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + + return new Response( + JSON.stringify(url.includes("/actions/") ? { output } : []), + { + headers: { "content-type": "application/json" }, + status: 200, + } + ) + }) + ) +} + +function prepareTriggerForDragging( + trigger: HTMLElement, + { left, top }: { left: number; top: number } +) { + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + bottom: top + 44, + height: 44, + left, + right: left + 80, + top, + width: 80, + x: left, + y: top, + toJSON: () => ({}), + }) + trigger.setPointerCapture = vi.fn() + trigger.hasPointerCapture = vi.fn(() => false) +} + describe("I18nDevtool", () => { + it("uses an explicitly configured control-panel locale", async () => { + mockDevtoolApi() + renderDevtool({ defaultOpen: true, locale: "zh-Hans" }) + + const dialog = await screen.findByRole("dialog") + const devtool = dialog.closest('[data-slot="i18n-devtool"]') + + expect(devtool?.getAttribute("lang")).toBe("zh-Hans") + expect(screen.getByText("操作")).toBeTruthy() + expect(screen.getByRole("button", { name: "提取" })).toBeTruthy() + expect(screen.getByRole("button", { name: "编译" })).toBeTruthy() + expect(screen.getByPlaceholderText("搜索消息")).toBeTruthy() + expect( + await screen.findByText("尚无可用消息。请先提取项目消息以生成目录。") + ).toBeTruthy() + }) + + it("detects the first supported navigator locale", async () => { + vi.spyOn(window.navigator, "languages", "get").mockReturnValue([ + "pt-BR", + "ja-JP", + "en-US", + ]) + renderDevtool() + + const trigger = await screen.findByRole("button", { + name: "I18n Devtool を開く", + }) + const devtool = trigger.closest('[data-slot="i18n-devtool"]') + + expect(devtool?.getAttribute("lang")).toBe("ja") + }) + + it("falls back to English when an explicit locale is unsupported", async () => { + vi.spyOn(window.navigator, "languages", "get").mockReturnValue(["zh-CN"]) + renderDevtool({ locale: "pt-BR" }) + + const trigger = await screen.findByRole("button", { + name: "Open I18n Devtool", + }) + const devtool = trigger.closest('[data-slot="i18n-devtool"]') + + expect(devtool?.getAttribute("lang")).toBe("en") + }) + + it("includes the project source locale as a customization target", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + const body = url.endsWith("/project") + ? { locales: ["en", "zh-Hans"], sourceLocale: "en" } + : [] + + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status: 200, + }) + }) + ) + + render( + + + + ) + + const localeSelect = await screen.findByRole("combobox", { + name: "Target message locale", + }) + + await waitFor(() => { + expect(localeSelect).toHaveProperty("value", "en") + }) + expect(screen.getByText("Source locale")).toBeTruthy() + expect(screen.getByText("English", { selector: "output" })).toBeTruthy() + expect(screen.getByRole("option", { name: "English" })).toBeTruthy() + expect(screen.getByRole("option", { name: "简体中文" })).toBeTruthy() + }) + it("tracks the configured document dark-mode class", async () => { render( { expect(devtool?.hasAttribute("data-dark")).toBe(true) }) }) + + it("preserves panel state while Activity hides the floating panel", async () => { + mockDevtoolApi() + renderDevtool() + + fireEvent.click( + await screen.findByRole("button", { + name: "Open I18n Devtool", + }) + ) + fireEvent.change(await screen.findByPlaceholderText("Search messages"), { + target: { value: "navigation" }, + }) + fireEvent.click(screen.getByRole("button", { name: "Close I18n Devtool" })) + fireEvent.click( + await screen.findByRole("button", { + name: "Open I18n Devtool", + }) + ) + + expect(screen.getByPlaceholderText("Search messages")).toHaveProperty( + "value", + "navigation" + ) + }) + + it("toggles full screen and lets Escape restore the floating panel", async () => { + mockDevtoolApi() + renderDevtool({ defaultOpen: true }) + + const dialog = await screen.findByRole("dialog") + const enterFullscreen = screen.getByRole("button", { + name: "Enter full screen", + }) + + expect(dialog.hasAttribute("data-fullscreen")).toBe(false) + fireEvent.click(enterFullscreen) + expect(dialog.hasAttribute("data-fullscreen")).toBe(true) + expect( + screen.getByRole("button", { name: "Exit full screen" }) + ).toBeTruthy() + + fireEvent.keyDown(window, { key: "Escape" }) + expect(dialog.hasAttribute("data-fullscreen")).toBe(false) + expect(screen.getByRole("dialog")).toBeTruthy() + }) + + it("expands a docked trigger while it is hovered or focused", async () => { + renderDevtool() + const trigger = await screen.findByRole("button", { + name: "Open I18n Devtool", + }) + + expect(trigger.getAttribute("data-dock")).toBe("right") + expect(trigger.hasAttribute("data-collapsed")).toBe(true) + + fireEvent.pointerEnter(trigger) + expect(trigger.hasAttribute("data-collapsed")).toBe(false) + + fireEvent.pointerLeave(trigger) + expect(trigger.hasAttribute("data-collapsed")).toBe(true) + + fireEvent.focus(trigger) + expect(trigger.hasAttribute("data-collapsed")).toBe(false) + }) + + it("keeps a docked trigger stationary when clicked and collapses it after closing", async () => { + renderDevtool() + const trigger = await screen.findByRole("button", { + name: "Open I18n Devtool", + }) + + prepareTriggerForDragging(trigger, { left: 944, top: 700 }) + fireEvent.pointerEnter(trigger) + fireEvent.focus(trigger) + fireEvent.pointerDown(trigger, { + button: 0, + clientX: 964, + clientY: 720, + pointerId: 1, + }) + + expect(trigger.getAttribute("data-dock")).toBe("right") + expect(trigger.hasAttribute("data-dragging")).toBe(false) + + fireEvent.pointerUp(trigger, { + clientX: 964, + clientY: 720, + pointerId: 1, + }) + fireEvent.click(trigger) + + fireEvent.click( + await screen.findByRole("button", { name: "Close I18n Devtool" }) + ) + + expect(trigger.getAttribute("data-dock")).toBe("right") + expect(trigger.hasAttribute("data-collapsed")).toBe(true) + }) + + it("only enters dragging state after a long press and does not open on release", async () => { + renderDevtool() + const trigger = await screen.findByRole("button", { + name: "Open I18n Devtool", + }) + + prepareTriggerForDragging(trigger, { left: 944, top: 700 }) + fireEvent.pointerEnter(trigger) + vi.useFakeTimers() + + try { + fireEvent.pointerDown(trigger, { + button: 0, + clientX: 964, + clientY: 720, + pointerId: 1, + }) + + expect(trigger.hasAttribute("data-dragging")).toBe(false) + + act(() => { + vi.advanceTimersByTime(400) + }) + expect(trigger.hasAttribute("data-dragging")).toBe(true) + + fireEvent.pointerUp(trigger, { + clientX: 964, + clientY: 720, + pointerId: 1, + }) + fireEvent.click(trigger) + + expect(trigger.hasAttribute("data-dragging")).toBe(false) + expect(screen.queryByRole("dialog")).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it("keeps a freely dragged trigger in place without opening the panel", async () => { + renderDevtool() + const trigger = await screen.findByRole("button", { + name: "Open I18n Devtool", + }) + + prepareTriggerForDragging(trigger, { left: 900, top: 700 }) + fireEvent.pointerDown(trigger, { + button: 0, + clientX: 920, + clientY: 720, + pointerId: 1, + }) + fireEvent.pointerMove(trigger, { + clientX: 420, + clientY: 270, + pointerId: 1, + }) + expect(trigger.hasAttribute("data-dragging")).toBe(true) + fireEvent.pointerUp(trigger, { + clientX: 420, + clientY: 270, + pointerId: 1, + }) + fireEvent.click(trigger) + + expect(trigger.hasAttribute("data-dock")).toBe(false) + expect(trigger.style.left).toBe("400px") + expect(trigger.style.top).toBe("250px") + expect(screen.queryByRole("dialog")).toBeNull() + }) + + it("docks and collapses after being dragged to a viewport edge", async () => { + renderDevtool() + const trigger = await screen.findByRole("button", { + name: "Open I18n Devtool", + }) + + prepareTriggerForDragging(trigger, { left: 400, top: 250 }) + fireEvent.focus(trigger) + fireEvent.pointerEnter(trigger) + fireEvent.pointerDown(trigger, { + button: 0, + clientX: 420, + clientY: 270, + pointerId: 1, + }) + fireEvent.pointerMove(trigger, { + clientX: 20, + clientY: 270, + pointerId: 1, + }) + fireEvent.pointerUp(trigger, { + clientX: 20, + clientY: 270, + pointerId: 1, + }) + fireEvent.pointerLeave(trigger) + + expect(trigger.getAttribute("data-dock")).toBe("left") + expect(trigger.hasAttribute("data-collapsed")).toBe(true) + expect(trigger.style.left).toBe("0px") + }) + + it("restores its persisted floating position after remounting", async () => { + const firstRender = renderDevtool() + const trigger = await screen.findByRole("button", { + name: "Open I18n Devtool", + }) + + prepareTriggerForDragging(trigger, { left: 900, top: 700 }) + fireEvent.pointerDown(trigger, { + button: 0, + clientX: 920, + clientY: 720, + pointerId: 1, + }) + fireEvent.pointerMove(trigger, { + clientX: 420, + clientY: 270, + pointerId: 1, + }) + fireEvent.pointerUp(trigger, { + clientX: 420, + clientY: 270, + pointerId: 1, + }) + + await waitFor(() => { + expect( + window.localStorage.getItem("i18n-devtool:floating-trigger-placement") + ).toContain('"x":400') + }) + + firstRender.unmount() + renderDevtool() + + const restoredTrigger = await screen.findByRole("button", { + name: "Open I18n Devtool", + }) + + expect(restoredTrigger.hasAttribute("data-dock")).toBe(false) + expect(restoredTrigger.style.left).toBe("400px") + expect(restoredTrigger.style.top).toBe("250px") + }) + + it("switches between automatic, light, and dark themes", async () => { + mockDevtoolApi() + renderDevtool({ defaultOpen: true }) + const devtool = await screen + .findByRole("dialog") + .then((dialog) => + dialog.closest('[data-slot="i18n-devtool"]') + ) + fireEvent.click( + screen.getByRole("button", { + name: "Open I18n Devtool settings", + }) + ) + const autoButton = screen.getByRole("button", { name: "Auto theme" }) + const lightButton = screen.getByRole("button", { name: "Light theme" }) + const darkButton = screen.getByRole("button", { name: "Dark theme" }) + + expect(autoButton.getAttribute("aria-pressed")).toBe("true") + expect(devtool?.getAttribute("data-theme")).toBe("auto") + + fireEvent.click(darkButton) + expect(darkButton.getAttribute("aria-pressed")).toBe("true") + expect(devtool?.hasAttribute("data-dark")).toBe(true) + + fireEvent.click(lightButton) + expect(lightButton.getAttribute("aria-pressed")).toBe("true") + expect(devtool?.hasAttribute("data-dark")).toBe(false) + }) + + it("positions the panel from settings and restores the preference", async () => { + mockDevtoolApi() + const firstRender = renderDevtool({ defaultOpen: true }) + const dialog = await screen.findByRole("dialog") + + expect(dialog.getAttribute("data-placement")).toBe("right") + fireEvent.click( + screen.getByRole("button", { + name: "Open I18n Devtool settings", + }) + ) + fireEvent.click(screen.getByRole("button", { name: "Center" })) + expect(dialog.getAttribute("data-placement")).toBe("center") + + fireEvent.click(screen.getByRole("button", { name: "Left" })) + expect(dialog.getAttribute("data-placement")).toBe("left") + + await waitFor(() => { + expect(window.localStorage.getItem("i18n-devtool:preferences")).toContain( + '"panelPlacement":"left"' + ) + }) + + firstRender.unmount() + renderDevtool({ defaultOpen: true }) + + expect( + (await screen.findByRole("dialog")).getAttribute("data-placement") + ).toBe("left") + }) + + it("switches and restores the Devtool interface locale from settings", async () => { + mockDevtoolApi() + const firstRender = renderDevtool({ defaultOpen: true }) + + fireEvent.click( + await screen.findByRole("button", { + name: "Open I18n Devtool settings", + }) + ) + + fireEvent.change( + screen.getByRole("combobox", { + name: "I18n Devtool interface language", + }), + { target: { value: "zh-Hans" } } + ) + + const dialog = screen.getByRole("dialog") + const devtool = dialog.closest('[data-slot="i18n-devtool"]') + + expect(devtool?.getAttribute("lang")).toBe("zh-Hans") + expect(screen.getByText("界面语言")).toBeTruthy() + expect(screen.getByPlaceholderText("搜索消息")).toBeTruthy() + + await waitFor(() => { + expect(window.localStorage.getItem("i18n-devtool:preferences")).toContain( + '"interfaceLocale":"zh-Hans"' + ) + }) + + firstRender.unmount() + renderDevtool({ defaultOpen: true }) + + const restoredDialog = await screen.findByRole("dialog") + expect( + restoredDialog + .closest('[data-slot="i18n-devtool"]') + ?.getAttribute("lang") + ).toBe("zh-Hans") + }) + + it("hides panel placement settings in standalone mode", async () => { + mockDevtoolApi() + renderDevtool({ + defaultOpen: true, + locale: "zh-Hans", + mode: "standalone", + }) + + fireEvent.click( + await screen.findByRole("button", { + name: "打开 I18n 开发工具设置", + }) + ) + + expect(screen.getByText("主题")).toBeTruthy() + expect(screen.getByText("界面语言")).toBeTruthy() + expect(screen.queryByText("面板位置")).toBeNull() + expect( + screen.queryByRole("group", { + name: "I18n 开发工具面板位置", + }) + ).toBeNull() + }) + + it("closes a floating panel when pressing outside it", async () => { + mockDevtoolApi() + renderDevtool({ defaultOpen: true }) + + const dialog = await screen.findByRole("dialog") + + fireEvent.pointerDown(dialog) + expect(screen.getByRole("dialog")).toBeTruthy() + + fireEvent.pointerDown(document.body) + + await waitFor(() => { + expect(screen.queryByRole("dialog")).toBeNull() + }) + expect( + screen.getByRole("button", { name: "Open I18n Devtool" }) + ).toBeTruthy() + }) + + it("keeps a standalone panel open when pressing outside it", async () => { + mockDevtoolApi() + renderDevtool({ mode: "standalone" }) + + const dialog = await screen.findByRole("dialog") + fireEvent.pointerDown(document.body) + + expect(screen.getByRole("dialog")).toBe(dialog) + }) + + it("collapses, expands, and closes command output", async () => { + mockDevtoolApi("\u001b[32mCatalog extraction complete\u001b[39m") + renderDevtool({ defaultOpen: true }) + + fireEvent.click(await screen.findByRole("button", { name: "Extract" })) + const output = await screen.findByText("Catalog extraction complete") + + expect(output.tagName).toBe("SPAN") + expect(output.getAttribute("style")).toContain("color:") + expect(output.textContent).not.toContain("\u001b") + + fireEvent.click( + screen.getByRole("button", { name: "Collapse command output" }) + ) + expect(screen.queryByText("Catalog extraction complete")).toBeNull() + + fireEvent.click( + screen.getByRole("button", { name: "Expand command output" }) + ) + expect(screen.getByText("Catalog extraction complete")).toBeTruthy() + + fireEvent.click( + screen.getByRole("button", { name: "Close command output" }) + ) + expect(screen.queryByText("Command output")).toBeNull() + }) + + it("refreshes compiled messages without remounting the message panel", async () => { + mockDevtoolApi("Compilation complete") + renderDevtool({ defaultOpen: true }) + + fireEvent.change(await screen.findByPlaceholderText("Search messages"), { + target: { value: "navigation" }, + }) + fireEvent.click(screen.getByRole("button", { name: "Compile" })) + + await screen.findByText("Compilation complete") + expect(screen.getByPlaceholderText("Search messages")).toHaveProperty( + "value", + "navigation" + ) + }) }) diff --git a/packages/i18n/src/devtool/i18n-devtool.tsx b/packages/i18n/src/devtool/i18n-devtool.tsx index 864d1c8..7b8f53a 100644 --- a/packages/i18n/src/devtool/i18n-devtool.tsx +++ b/packages/i18n/src/devtool/i18n-devtool.tsx @@ -1,9 +1,28 @@ import * as React from "react" +import { Maximize2Icon, Minimize2Icon, XIcon } from "lucide-react" import { createPortal } from "react-dom" import { useLocale, useLocales } from "../runtime" -import { runDevtoolAction, type DevtoolAction } from "./api-client" +import { + requestDevtoolProject, + runDevtoolAction, + type DevtoolAction, +} from "./api-client" import { createHttpMessageRepository } from "./http-message-repository" +import { DevtoolActionOutput } from "./devtool-action-output" +import { DevtoolLocaleSelect } from "./devtool-locale-select" +import { + useDevtoolPreferences, + type I18nDevtoolPanelPlacement, + type I18nDevtoolTheme, +} from "./devtool-preferences" +import { + DevtoolLocalizationProvider, + getI18nDevtoolMessages, + resolveI18nDevtoolLocale, +} from "./devtool-localization" +import { DevtoolSettings } from "./devtool-settings" +import { I18nDevtoolTrigger } from "./i18n-devtool-trigger" import { MessagePanel } from "./message-panel" import { MessageRepositoryProvider } from "./message-repository" import "./i18n-devtool.css" @@ -13,7 +32,20 @@ export interface I18nDevtoolProps { className?: string dark?: string defaultOpen?: boolean + defaultPanelPlacement?: I18nDevtoolPanelPlacement + defaultTheme?: I18nDevtoolTheme + /** + * Initial locale used by the Devtool controls. The user can override it in + * settings. When omitted, the first supported locale from + * `navigator.languages` is used, with English as the fallback. + */ + locale?: string mode?: "floating" | "standalone" + /** + * Source locale used by the project. It is excluded from the editable target + * locale list. When omitted, the Devtool reads it from its project endpoint. + */ + sourceLocale?: string } function matchesDarkSelector(element: Element, dark: string) { @@ -73,38 +105,98 @@ export function I18nDevtool({ className, dark, defaultOpen = false, + defaultPanelPlacement = "right", + defaultTheme = "auto", + locale: devtoolLocale, mode = "floating", + sourceLocale, }: I18nDevtoolProps) { const activeLocale = useLocale() const locales = useLocales() + const defaultInterfaceLocale = resolveI18nDevtoolLocale(devtoolLocale) + const [projectSourceLocale, setProjectSourceLocale] = + React.useState(sourceLocale) const [portalTarget, setPortalTarget] = React.useState( null ) + const panelRef = React.useRef(null) const [isOpen, setOpen] = React.useState(defaultOpen || mode === "standalone") - const [locale, setLocale] = React.useState(activeLocale) - const [reloadKey, setReloadKey] = React.useState(0) + const [messageLocale, setMessageLocale] = React.useState( + () => activeLocale ?? locales[0]?.locale ?? "" + ) + const [messageRevision, setMessageRevision] = React.useState(0) const [actionState, setActionState] = React.useState( "idle" ) const [actionOutput, setActionOutput] = React.useState("") + const [isActionOutputExpanded, setActionOutputExpanded] = React.useState(true) + const [isFullscreen, setFullscreen] = React.useState(false) const [error, setError] = React.useState("") - const isDark = useDarkMode(dark) + const { + interfaceLocale: resolvedDevtoolLocale, + panelPlacement, + setInterfaceLocale, + setPanelPlacement, + setTheme, + theme, + } = useDevtoolPreferences({ + defaultInterfaceLocale, + defaultPanelPlacement, + defaultTheme, + }) + const messages = getI18nDevtoolMessages(resolvedDevtoolLocale) + const prefersDark = useDarkMode(dark) + const isDark = theme === "dark" || (theme === "auto" && prefersDark) const repository = React.useMemo( () => createHttpMessageRepository(apiBaseUrl), [apiBaseUrl] ) + const sourceLocaleLabel = projectSourceLocale + ? (locales.find((definition) => definition.locale === projectSourceLocale) + ?.label ?? projectSourceLocale) + : undefined React.useEffect(() => { setPortalTarget(document.body) }, []) React.useEffect(() => { - if (locales.some((definition) => definition.locale === locale)) { + if (sourceLocale) { + setProjectSourceLocale(sourceLocale) return } - setLocale(activeLocale) - }, [activeLocale, locale, locales]) + let cancelled = false + + setProjectSourceLocale(undefined) + void requestDevtoolProject(apiBaseUrl).then( + (project) => { + if (!cancelled && typeof project.sourceLocale === "string") { + setProjectSourceLocale(project.sourceLocale) + } + }, + () => { + // The rest of the Devtool remains usable when project metadata is not + // exposed by a custom backend. + } + ) + + return () => { + cancelled = true + } + }, [apiBaseUrl, sourceLocale]) + + React.useEffect(() => { + if (locales.some((definition) => definition.locale === messageLocale)) { + return + } + + const nextLocale = + locales.find((definition) => definition.locale === activeLocale) ?? + locales[0] + + setMessageLocale(nextLocale?.locale ?? "") + }, [activeLocale, locales, messageLocale]) React.useEffect(() => { if (!isOpen || mode === "standalone") { @@ -113,25 +205,67 @@ export function I18nDevtool({ const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { - setOpen(false) + if (isFullscreen) { + setFullscreen(false) + } else { + setOpen(false) + } } } window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) + }, [isFullscreen, isOpen, mode]) + + React.useEffect(() => { + if (!isOpen || mode === "standalone") { + return + } + + const handlePointerDown = (event: PointerEvent) => { + if ( + event.target instanceof Node && + !panelRef.current?.contains(event.target) + ) { + setFullscreen(false) + setOpen(false) + } + } + + document.addEventListener("pointerdown", handlePointerDown, true) + + return () => { + document.removeEventListener("pointerdown", handlePointerDown, true) + } }, [isOpen, mode]) const runAction = async (action: DevtoolAction) => { setActionState(action) - setActionOutput("") + setActionOutput( + action === "extract" + ? messages.actions.extracting + : messages.actions.compiling + ) + setActionOutputExpanded(true) setError("") try { const result = await runDevtoolAction(action, apiBaseUrl) - setActionOutput(result.output.trim() || `${action} completed.`) - setReloadKey((current) => current + 1) + setActionOutput( + result.output.trim() || + (action === "extract" + ? messages.actions.extractCompleted + : messages.actions.compileCompleted) + ) + setMessageRevision((current) => current + 1) } catch (reason) { - setError(reason instanceof Error ? reason.message : `${action} failed.`) + setError( + reason instanceof Error + ? reason.message + : action === "extract" + ? messages.actions.extractFailed + : messages.actions.compileFailed + ) } finally { setActionState("idle") } @@ -142,99 +276,165 @@ export function I18nDevtool({ } const rootClassName = ["i18n-devtool", className].filter(Boolean).join(" ") + const closeActionOutput = () => { + setActionOutput("") + setError("") + } + const closePanel = () => { + setFullscreen(false) + setOpen(false) + } return createPortal( -
- {mode === "floating" && !isOpen && ( - - )} + +
+ {mode === "floating" && ( + setOpen(true)} /> + )} - {isOpen && ( -
- - - {(error || actionOutput) && ( -
-              {error || actionOutput}
-            
- )} - -
- - - -
- - )} -
, + + + + + , portalTarget ) } diff --git a/packages/i18n/src/devtool/index.ts b/packages/i18n/src/devtool/index.ts index ad9b7ef..5d62195 100644 --- a/packages/i18n/src/devtool/index.ts +++ b/packages/i18n/src/devtool/index.ts @@ -1,5 +1,18 @@ export { createHttpMessageRepository } from "./http-message-repository" export { I18nDevtool, type I18nDevtoolProps } from "./i18n-devtool" +export { + I18nDevtoolTrigger, + type I18nDevtoolTriggerProps, +} from "./i18n-devtool-trigger" +export { + I18N_DEVTOOL_LOCALES, + type I18nDevtoolLocale, + resolveI18nDevtoolLocale, +} from "./devtool-localization" +export type { + I18nDevtoolPanelPlacement, + I18nDevtoolTheme, +} from "./devtool-preferences" export { MessagePanel, type MessagePanelProps } from "./message-panel" export { MessageRepositoryProvider, @@ -8,6 +21,7 @@ export { } from "./message-repository" export type { DevtoolMessage, + DevtoolMessagePackage, MessageOrigin, MessageRepository, UpdateMessageInput, diff --git a/packages/i18n/src/devtool/message-copy-menu.tsx b/packages/i18n/src/devtool/message-copy-menu.tsx new file mode 100644 index 0000000..83bfaff --- /dev/null +++ b/packages/i18n/src/devtool/message-copy-menu.tsx @@ -0,0 +1,285 @@ +import * as React from "react" +import { + CheckIcon, + ChevronDownIcon, + CopyIcon, + TriangleAlertIcon, +} from "lucide-react" + +import { useDevtoolLocalization } from "./devtool-localization" +import type { DevtoolMessage, MessageOrigin } from "./types" + +function getOriginLocation(origin: MessageOrigin) { + return `${origin.file}${origin.line ? `:${origin.line}` : ""}` +} + +export function formatMessageOrigin(origin: MessageOrigin) { + const normalizedFile = origin.file.replaceAll("\\", "/") + const packageRelativeFile = + normalizedFile.match(/(?:^|\/)(?:apps|packages)\/[^/]+\/(.+)$/)?.[1] ?? + normalizedFile.replace(/^(?:\.\.\/)+/, "") + + return `${packageRelativeFile}${origin.line ? `:${origin.line}` : ""}` +} + +function serializeMessageDescriptor(message: DevtoolMessage) { + return [ + "{", + ` id: ${JSON.stringify(message.id)},`, + ` message: ${JSON.stringify(message.source || message.id)},`, + "}", + ].join("\n") +} + +async function writeClipboard(value: string) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value) + return + } + + const textarea = document.createElement("textarea") + textarea.value = value + textarea.style.position = "fixed" + textarea.style.opacity = "0" + document.body.appendChild(textarea) + textarea.select() + + try { + if (!document.execCommand("copy")) { + throw new Error("The browser rejected the clipboard operation.") + } + } finally { + textarea.remove() + } +} + +export function MessageCopyMenu({ + message, + translation, +}: { + message: DevtoolMessage + translation: string +}) { + const { messages } = useDevtoolLocalization() + const [isOpen, setOpen] = React.useState(false) + const [copyState, setCopyState] = React.useState<"idle" | "copied" | "error">( + "idle" + ) + const rootRef = React.useRef(null) + const triggerRef = React.useRef(null) + + React.useEffect(() => { + if (!isOpen) { + return + } + + const handlePointerDown = (event: PointerEvent) => { + if ( + event.target instanceof Node && + !rootRef.current?.contains(event.target) + ) { + setOpen(false) + } + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault() + setOpen(false) + triggerRef.current?.focus() + } + } + + document.addEventListener("pointerdown", handlePointerDown) + document.addEventListener("keydown", handleKeyDown) + + return () => { + document.removeEventListener("pointerdown", handlePointerDown) + document.removeEventListener("keydown", handleKeyDown) + } + }, [isOpen]) + + React.useEffect(() => { + if (copyState === "idle") { + return + } + + const timeout = window.setTimeout(() => setCopyState("idle"), 1600) + return () => window.clearTimeout(timeout) + }, [copyState]) + + const openMenu = () => { + setOpen(true) + window.setTimeout(() => { + rootRef.current + ?.querySelector('[role="menuitem"]:not(:disabled)') + ?.focus() + }) + } + + const closeMenu = () => { + setOpen(false) + triggerRef.current?.focus() + } + + const copy = async (value: string) => { + try { + await writeClipboard(value) + setCopyState("copied") + } catch { + setCopyState("error") + } finally { + closeMenu() + } + } + + const handleMenuKeyDown = (event: React.KeyboardEvent) => { + if (!["ArrowDown", "ArrowUp", "End", "Home"].includes(event.key)) { + return + } + + event.preventDefault() + const items = Array.from( + event.currentTarget.querySelectorAll( + '[role="menuitem"]:not(:disabled)' + ) + ) + + if (items.length === 0) { + return + } + + const currentIndex = items.indexOf( + document.activeElement as HTMLButtonElement + ) + const nextIndex = + event.key === "Home" + ? 0 + : event.key === "End" + ? items.length - 1 + : event.key === "ArrowUp" + ? (currentIndex - 1 + items.length) % items.length + : (currentIndex + 1) % items.length + + items[nextIndex]?.focus() + } + + return ( +
+ + + {isOpen && ( +
+ + + + + {message.origins.length > 0 && ( + <> +
+ + {messages.messagePanel.sourceLocations} + + {message.origins.map((origin, index) => ( + + ))} + + )} + +
+ +
+ )} + + {copyState !== "idle" && !isOpen && ( + + {copyState === "copied" ? ( + + )} +
+ ) +} diff --git a/packages/i18n/src/devtool/message-navigation.tsx b/packages/i18n/src/devtool/message-navigation.tsx new file mode 100644 index 0000000..2d86288 --- /dev/null +++ b/packages/i18n/src/devtool/message-navigation.tsx @@ -0,0 +1,356 @@ +import * as React from "react" +import { ChevronDownIcon, FolderIcon, PackageIcon } from "lucide-react" + +import { useDevtoolLocalization } from "./devtool-localization" +import type { DevtoolMessage, DevtoolMessagePackage } from "./types" + +export interface MessagePackageGroup { + kind: DevtoolMessagePackage["kind"] + messages: readonly DevtoolMessage[] + missingCount: number + packageName: string +} + +interface MessageNamespaceNode { + children: Map + descendantCount: number + messages: DevtoolMessage[] + path: string + segment: string +} + +interface MessagePackageNode extends MessagePackageGroup { + messagesAtRoot: readonly DevtoolMessage[] + namespaces: Map + selectionKey: string +} + +export interface MessageNavigationSelection { + key: string + label: string + messages: readonly DevtoolMessage[] + packageName: string +} + +export interface MessageNavigationModel { + packages: readonly MessagePackageNode[] + selections: ReadonlyMap +} + +function getPackageSelectionKey(packageName: string) { + return `package:${packageName}` +} + +function getNamespaceSelectionKey(packageName: string, path: string) { + return `package:${packageName}:namespace:${path}` +} + +export function createMessageNavigationModel( + groups: readonly MessagePackageGroup[] +): MessageNavigationModel { + const selections = new Map() + const packages = groups.map((group) => { + const messagesAtRoot: DevtoolMessage[] = [] + const namespaces = new Map() + + for (const message of group.messages) { + const segments = message.id.split(".").filter(Boolean) + + if (segments.length <= 1) { + messagesAtRoot.push(message) + continue + } + + const namespaceSegments = segments.slice(0, -1) + const namespacePath = namespaceSegments.join(".") + let children = namespaces + let path = "" + + for (const segment of namespaceSegments) { + path = path ? `${path}.${segment}` : segment + let node = children.get(segment) + + if (!node) { + node = { + children: new Map(), + descendantCount: 0, + messages: [], + path, + segment, + } + children.set(segment, node) + } + + node.descendantCount += 1 + children = node.children + + if (path === namespacePath) { + node.messages.push(message) + } + } + } + + const selectionKey = getPackageSelectionKey(group.packageName) + selections.set(selectionKey, { + key: selectionKey, + label: group.packageName, + messages: messagesAtRoot, + packageName: group.packageName, + }) + + const addNamespaceSelections = ( + nodes: Map + ) => { + for (const node of nodes.values()) { + const key = getNamespaceSelectionKey(group.packageName, node.path) + selections.set(key, { + key, + label: node.path, + messages: node.messages, + packageName: group.packageName, + }) + addNamespaceSelections(node.children) + } + } + + addNamespaceSelections(namespaces) + + return { + ...group, + messagesAtRoot, + namespaces, + selectionKey, + } + }) + + return { packages, selections } +} + +function NamespaceBranch({ + nodes, + onSelect, + packageName, + selectedKey, +}: { + nodes: Map + onSelect: (key: string) => void + packageName: string + selectedKey?: string +}) { + return ( +
    + {Array.from(nodes.values()) + .sort((left, right) => left.segment.localeCompare(right.segment)) + .map((node) => ( + + ))} +
+ ) +} + +function NamespaceNode({ + node, + onSelect, + packageName, + selectedKey, +}: { + node: MessageNamespaceNode + onSelect: (key: string) => void + packageName: string + selectedKey?: string +}) { + const [isExpanded, setExpanded] = React.useState(true) + const { messages } = useDevtoolLocalization() + const selectionKey = getNamespaceSelectionKey(packageName, node.path) + const hasChildren = node.children.size > 0 + const hasMessages = node.messages.length > 0 + const isSelected = selectionKey === selectedKey + + return ( +
  • +
    + + +
    + {hasChildren && ( + + )} +
  • + ) +} + +function PackageTree({ + node, + onSelect, + selectedKey, +}: { + node: MessagePackageNode + onSelect: (key: string) => void + selectedKey?: string +}) { + const { messages } = useDevtoolLocalization() + const [isExpanded, setExpanded] = React.useState(true) + const contentId = React.useId() + const hasNamespaces = node.namespaces.size > 0 + const hasContent = node.messages.length === 0 || hasNamespaces + const isSelected = node.selectionKey === selectedKey + + return ( +
    +
    + + + + + {messages.messagePanel.messageCount(node.messages.length)} + + {node.missingCount > 0 && ( + + {messages.messagePanel.missingCount(node.missingCount)} + + )} + + + +
    + {hasContent && ( + + )} +
    + ) +} + +export function MessageNavigation({ + model, + onSelect, + selectedKey, +}: { + model: MessageNavigationModel + onSelect: (key: string) => void + selectedKey?: string +}) { + const { messages } = useDevtoolLocalization() + + return ( + + ) +} diff --git a/packages/i18n/src/devtool/message-panel.css b/packages/i18n/src/devtool/message-panel.css index 4db30af..ac4fc3a 100644 --- a/packages/i18n/src/devtool/message-panel.css +++ b/packages/i18n/src/devtool/message-panel.css @@ -3,6 +3,9 @@ --i18n-muted: color-mix(in srgb, currentColor 62%, transparent); --i18n-surface: color-mix(in srgb, Canvas 96%, currentColor 4%); color: CanvasText; + display: flex; + min-height: 0; + flex-direction: column; font-family: Inter, ui-sans-serif, @@ -33,8 +36,21 @@ } .i18n-message-panel__search { + position: relative; + display: grid; min-width: 12rem; flex: 1; + align-items: center; +} + +.i18n-message-panel__search > svg { + position: absolute; + left: 0.75rem; + z-index: 1; + width: 1rem; + height: 1rem; + color: var(--i18n-muted); + pointer-events: none; } .i18n-message-panel input, @@ -55,16 +71,24 @@ .i18n-message-panel input[type="search"] { height: 2.5rem; - padding: 0 0.75rem; + padding: 0 3.25rem 0 2.25rem; } .i18n-message-panel textarea { - min-height: 5.5rem; + min-height: 3.25rem; + max-height: 14rem; + field-sizing: content; resize: vertical; - padding: 0.75rem; + padding: 0.65rem 0.75rem; line-height: 1.5; } +.i18n-message-panel textarea:read-only { + background: color-mix(in srgb, Canvas 92%, currentColor 8%); + color: var(--i18n-muted); + cursor: default; +} + .i18n-message-panel input[type="search"]:focus, .i18n-message-panel textarea:focus { border-color: AccentColor; @@ -72,32 +96,464 @@ } .i18n-message-panel__toggle { + position: relative; display: inline-flex; align-items: center; gap: 0.4rem; + padding: 0.25rem 0.4rem; + border-radius: 0.45rem; + cursor: pointer; white-space: nowrap; font-size: 0.875rem; + transition: background-color 120ms ease; +} + +.i18n-message-panel__toggle:hover { + background: color-mix(in srgb, Canvas 90%, currentColor 10%); +} + +.i18n-message-panel__toggle:active { + background: color-mix(in srgb, Canvas 84%, currentColor 16%); +} + +.i18n-message-panel__toggle input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + opacity: 0; + pointer-events: none; +} + +.i18n-message-panel__checkbox { + display: inline-grid; + width: 1.125rem; + height: 1.125rem; + flex: 0 0 1.125rem; + place-items: center; + border: 1px solid var(--i18n-border); + border-radius: 0.25rem; + background: Canvas; + color: white; + transition: + background-color 120ms ease, + border-color 120ms ease, + box-shadow 120ms ease, + transform 80ms ease; +} + +.i18n-message-panel__checkbox svg { + width: 0.875rem; + height: 0.875rem; + opacity: 0; + stroke-width: 3; + transform: scale(0.6); + transition: + opacity 100ms ease, + transform 120ms ease; +} + +.i18n-message-panel__toggle input:checked + .i18n-message-panel__checkbox { + border-color: AccentColor; + background: AccentColor; +} + +.i18n-message-panel__toggle input:checked + .i18n-message-panel__checkbox svg { + opacity: 1; + transform: scale(1); +} + +.i18n-message-panel__toggle + input:focus-visible + + .i18n-message-panel__checkbox { + box-shadow: 0 0 0 3px color-mix(in srgb, AccentColor 22%, transparent); +} + +.i18n-message-panel__toggle:active .i18n-message-panel__checkbox { + transform: scale(0.92); } .i18n-message-panel__count { + display: inline-grid; + min-width: 1.5rem; + height: 1.5rem; + place-items: center; + padding: 0 0.35rem; + border-radius: 999px; + background: color-mix(in srgb, Canvas 86%, currentColor 14%); color: var(--i18n-muted); font-variant-numeric: tabular-nums; - font-size: 0.8125rem; + font-size: 0.75rem; } -.i18n-message-panel__list { +.i18n-message-panel__result-count { + position: absolute; + right: 0.55rem; + display: inline-grid; + min-width: 1.75rem; + height: 1.5rem; + place-items: center; + padding: 0 0.4rem; + border-radius: 999px; + background: color-mix(in srgb, Canvas 84%, currentColor 16%); + color: var(--i18n-muted); + font-variant-numeric: tabular-nums; + font-size: 0.75rem; + pointer-events: none; +} + +.i18n-message-workspace { display: grid; - gap: 0.75rem; + min-height: 30rem; + flex: 1; + grid-template-columns: minmax(18rem, min(28rem, 44%)) minmax(0, 1fr); +} + +.i18n-message-navigation, +.i18n-message-workspace__editor { + min-width: 0; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.i18n-message-navigation { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.65rem; padding: 0.75rem; + border-right: 1px solid var(--i18n-border); + background: color-mix(in srgb, Canvas 94%, currentColor 6%); +} + +.i18n-message-workspace__editor { + padding: 0.75rem; +} + +.i18n-message-workspace__editor-list { + display: grid; + align-content: start; + gap: 0.75rem; +} + +.i18n-message-workspace__empty { + display: grid; + min-height: 100%; + place-items: center; + padding: 2rem; + color: var(--i18n-muted); + text-align: center; +} + +.i18n-message-package { + display: grid; + min-width: 0; + flex: none; + overflow: hidden; + border: 1px solid var(--i18n-border); + border-radius: 0.75rem; + background: color-mix(in srgb, Canvas 91%, currentColor 9%); + box-shadow: 0 0.15rem 0.5rem rgb(0 0 0 / 4%); +} + +.i18n-message-package__messages[hidden] { + display: none; +} + +.i18n-message-package__messages { + position: relative; + display: grid; + padding: 0.45rem 0.45rem 0.55rem 0.7rem; + border-top: 1px solid var(--i18n-border); + background: color-mix(in srgb, Canvas 96%, currentColor 4%); +} + +.i18n-message-package__empty { + display: grid; + min-height: 5rem; + place-items: center; + padding: 1rem; + border: 1px dashed color-mix(in srgb, currentColor 18%, transparent); + border-radius: 0.55rem; + background: color-mix(in srgb, Canvas 80%, transparent); + color: var(--i18n-muted); + font-size: 0.8125rem; + text-align: center; +} + +.i18n-message-package__header { + display: flex; + min-width: 0; + min-height: 3rem; + align-items: center; + justify-content: space-between; + gap: 0.35rem; + padding: 0.3rem; + background: color-mix(in srgb, Canvas 86%, currentColor 14%); +} + +.i18n-message-panel button.i18n-message-package__identity { + display: flex; + min-width: 0; + min-height: 2.35rem; + flex: 1; + justify-content: flex-start; + gap: 0.6rem; + padding: 0.25rem 0.35rem; + border: 0; + border-radius: 0.5rem; + outline: none; + background: transparent; + text-align: start; +} + +.i18n-message-panel button.i18n-message-package__identity:hover:not(:disabled), +.i18n-message-panel + .i18n-message-package[data-selected] + button.i18n-message-package__identity { + background: color-mix(in srgb, Canvas 80%, currentColor 20%); +} + +.i18n-message-panel button.i18n-message-package__identity:focus-visible, +.i18n-message-panel button.i18n-message-package__disclosure:focus-visible { + box-shadow: inset 0 0 0 2px AccentColor; +} + +.i18n-message-package__identity code { + min-width: 0; + overflow: hidden; + font-size: 0.8125rem; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.i18n-message-package__icon { + display: inline-grid; + width: 1.9rem; + height: 1.9rem; + flex: 0 0 1.9rem; + place-items: center; + border-radius: 0.5rem; + background: color-mix(in srgb, AccentColor 14%, Canvas); + color: AccentColor; +} + +.i18n-message-package__icon svg { + width: 1rem; + height: 1rem; +} + +.i18n-message-package__counts, +.i18n-message-package__controls { + display: inline-flex; + min-width: 0; + align-items: center; +} + +.i18n-message-package__counts { + flex: none; + gap: 0.35rem; +} + +.i18n-message-package__controls { + flex: none; + gap: 0.25rem; +} + +.i18n-message-panel button.i18n-message-package__disclosure { + width: 2rem; + min-height: 0; + height: 2rem; + flex: 0 0 2rem; + padding: 0; + border: 0; + border-radius: 0.45rem; + outline: none; + background: transparent; +} + +.i18n-message-package__chevron { + width: 1rem; + height: 1rem; + flex: 0 0 1rem; + color: var(--i18n-muted); + transition: rotate 140ms ease; +} + +.i18n-message-package__disclosure[aria-expanded="false"] + .i18n-message-package__chevron { + rotate: -90deg; +} + +.i18n-message-package__disclosure[aria-expanded="false"]:dir(rtl) + .i18n-message-package__chevron { + rotate: 90deg; +} + +.i18n-message-package__counts span { + padding: 0.2rem 0.45rem; + border-radius: 999px; + background: color-mix(in srgb, Canvas 84%, currentColor 16%); + color: var(--i18n-muted); + font-variant-numeric: tabular-nums; + font-size: 0.6875rem; + white-space: nowrap; +} + +.i18n-message-package__counts span[data-missing] { + background: color-mix(in srgb, #e8a317 14%, Canvas); + color: color-mix(in srgb, #b36b00 86%, CanvasText); +} + +.i18n-message-tree__branch { + position: relative; + display: grid; + gap: 0.2rem; + margin: 0; + padding: 0; + list-style: none; +} + +.i18n-message-tree__branch .i18n-message-tree__branch { + margin-block: 0.15rem 0.25rem; + margin-inline-start: 0.85rem; + padding-inline-start: 0.75rem; + border-inline-start: 1px solid + color-mix(in srgb, currentColor 18%, transparent); +} + +.i18n-message-tree__branch li { + min-width: 0; +} + +.i18n-message-tree__namespace-row { + display: grid; + min-width: 0; + grid-template-columns: 1.65rem minmax(0, 1fr); + align-items: center; + border-radius: 0.5rem; + transition: + color 120ms ease, + background-color 120ms ease; +} + +.i18n-message-tree__namespace-row:hover { + background: color-mix(in srgb, Canvas 82%, currentColor 18%); +} + +.i18n-message-tree__namespace-row[data-selected] { + background: color-mix(in srgb, AccentColor 14%, Canvas); + color: color-mix(in srgb, AccentColor 82%, CanvasText); +} + +.i18n-message-panel button.i18n-message-tree__disclosure { + width: 1.65rem; + min-height: 1.9rem; + padding: 0; + border: 0; + border-radius: 0.4rem; + outline: none; + background: transparent; +} + +.i18n-message-panel button.i18n-message-tree__disclosure:focus-visible, +.i18n-message-panel button.i18n-message-tree__namespace:focus-visible { + box-shadow: inset 0 0 0 2px AccentColor; +} + +.i18n-message-panel button.i18n-message-tree__namespace { + display: flex; + width: 100%; + min-width: 0; + min-height: 2.15rem; + align-items: center; + justify-content: flex-start; + gap: 0.45rem; + padding: 0.35rem 0.5rem 0.35rem 0.2rem; + border: 0; + border-radius: 0.5rem; + outline: none; + background: transparent; + text-align: start; +} + +.i18n-message-panel button.i18n-message-tree__namespace[data-selectable] { + font-weight: 600; +} + +.i18n-message-tree__namespace > span { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.i18n-message-tree__namespace small { + margin-inline-start: auto; + color: var(--i18n-muted); + font-variant-numeric: tabular-nums; + font-size: 0.6875rem; +} + +.i18n-message-tree__namespace > svg { + width: 0.9rem; + height: 0.9rem; + flex: 0 0 0.9rem; + color: var(--i18n-muted); +} + +.i18n-message-tree__chevron { + width: 0.9rem; + height: 0.9rem; + color: var(--i18n-muted); + transition: rotate 120ms ease; +} + +.i18n-message-tree__disclosure[aria-expanded="false"] + .i18n-message-tree__chevron { + rotate: -90deg; +} + +.i18n-message-tree__disclosure[aria-expanded="false"]:dir(rtl) + .i18n-message-tree__chevron { + rotate: 90deg; } .i18n-message { display: grid; - gap: 0.875rem; - padding: 1rem; + gap: 0.65rem; + overflow: visible; + padding: 0.875rem; border: 1px solid var(--i18n-border); - border-radius: 0.875rem; + border-radius: 0.75rem; background: var(--i18n-surface); + transition: + border-color 120ms ease, + box-shadow 120ms ease, + background-color 120ms ease; +} + +.i18n-message:not(:has(.i18n-message-copy__trigger[aria-expanded="true"])) { + content-visibility: auto; + contain-intrinsic-block-size: 13rem; +} + +.i18n-message:hover { + border-color: color-mix(in srgb, currentColor 22%, transparent); +} + +.i18n-message:focus-within { + border-color: color-mix(in srgb, AccentColor 55%, var(--i18n-border)); + box-shadow: 0 0 0 3px color-mix(in srgb, AccentColor 10%, transparent); +} + +.i18n-message[data-dirty] { + border-color: color-mix(in srgb, AccentColor 42%, var(--i18n-border)); } .i18n-message[data-missing] { @@ -108,6 +564,210 @@ opacity: 0.65; } +.i18n-message__source-toolbar { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin: -0.875rem -0.875rem 0; + padding: 0.3rem 0.5rem; + border-bottom: 1px solid var(--i18n-border); + border-radius: 0.75rem 0.75rem 0 0; + background: color-mix(in srgb, Canvas 88%, currentColor 12%); +} + +.i18n-message__source-location { + display: flex; + min-width: 0; + align-items: center; + gap: 0.4rem; + color: var(--i18n-muted); +} + +.i18n-message__source-location > svg { + width: 1rem; + height: 1rem; + flex: 0 0 1rem; +} + +.i18n-message__source-location > code { + min-width: 0; + overflow: hidden; + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.i18n-message__source-count { + display: inline-grid; + min-width: 1.35rem; + height: 1.25rem; + flex: none; + place-items: center; + padding: 0 0.3rem; + border-radius: 999px; + background: color-mix(in srgb, Canvas 78%, currentColor 22%); + color: var(--i18n-muted); + font-variant-numeric: tabular-nums; + font-size: 0.6875rem; +} + +.i18n-message-copy { + position: relative; + flex: none; +} + +.i18n-message-panel button.i18n-message-copy__trigger { + width: auto; + min-height: 1.625rem; + gap: 0.15rem; + padding: 0 0.25rem; + border: 0; + background: transparent; +} + +.i18n-message-panel button.i18n-message-copy__trigger:hover:not(:disabled), +.i18n-message-panel button.i18n-message-copy__trigger[aria-expanded="true"] { + background: transparent; + color: color-mix(in srgb, currentColor 78%, AccentColor 22%); +} + +.i18n-message-copy__trigger[data-state="copied"] { + color: #16a34a; +} + +.i18n-message-copy__trigger[data-state="error"] { + color: #dc2626; +} + +.i18n-message-copy__chevron { + width: 0.75rem; + height: 0.75rem; + transition: rotate 120ms ease; +} + +.i18n-message-copy__trigger[aria-expanded="true"] .i18n-message-copy__chevron { + rotate: 180deg; +} + +.i18n-message-copy__menu { + position: absolute; + z-index: 10; + top: calc(100% + 0.35rem); + right: 0; + display: grid; + width: min(18rem, calc(100vw - 4rem)); + padding: 0.3rem; + border: 1px solid var(--i18n-border); + border-radius: 0.65rem; + background: Canvas; + box-shadow: + 0 0.75rem 2.5rem rgb(0 0 0 / 14%), + 0 0.125rem 0.5rem rgb(0 0 0 / 8%); +} + +.i18n-message-panel .i18n-message-copy__menu > button { + width: 100%; + min-height: 2.25rem; + justify-content: flex-start; + padding: 0.45rem 0.55rem; + border: 0; + background: transparent; + text-align: start; +} + +.i18n-message-panel .i18n-message-copy__menu > button:hover:not(:disabled), +.i18n-message-panel .i18n-message-copy__menu > button:focus-visible { + outline: 2px solid AccentColor; + outline-offset: -2px; + background: color-mix(in srgb, Canvas 88%, currentColor 12%); +} + +.i18n-message-panel .i18n-message-copy__menu > button:hover:not(:disabled) { + outline-color: transparent; +} + +.i18n-message-copy__menu > button:has(code) { + display: grid; + justify-items: start; + gap: 0.1rem; +} + +.i18n-message-copy__menu button code { + display: block; + width: 100%; + overflow: hidden; + color: var(--i18n-muted); + font-size: 0.6875rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.i18n-message-copy__separator { + height: 1px; + margin: 0.3rem -0.3rem; + background: var(--i18n-border); +} + +.i18n-message-copy__label { + padding: 0.35rem 0.55rem 0.2rem; + color: var(--i18n-muted); + font-size: 0.6875rem; + font-weight: 650; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.i18n-message-copy__feedback { + position: absolute; + z-index: 11; + top: calc(100% + 0.35rem); + right: 0; + display: inline-flex; + min-height: 1.875rem; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.55rem; + border: 1px solid var(--i18n-border); + border-radius: 0.5rem; + background: Canvas; + box-shadow: + 0 0.5rem 1.5rem rgb(0 0 0 / 12%), + 0 0.125rem 0.375rem rgb(0 0 0 / 7%); + color: var(--i18n-muted); + font-size: 0.75rem; + font-weight: 600; + pointer-events: none; + white-space: nowrap; + animation: i18n-message-copy-feedback-in 140ms ease-out; +} + +.i18n-message-copy__feedback[data-state="copied"] { + color: #15803d; +} + +.i18n-message-copy__feedback[data-state="error"] { + color: #dc2626; +} + +.i18n-message-copy__feedback > svg { + width: 0.875rem; + height: 0.875rem; +} + +@keyframes i18n-message-copy-feedback-in { + from { + opacity: 0; + transform: translateY(-0.2rem) scale(0.96); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + .i18n-message__header, .i18n-message__actions, .i18n-message__meta { @@ -118,20 +778,31 @@ .i18n-message__header { justify-content: space-between; + min-width: 0; } .i18n-message__id { + min-width: 0; overflow-wrap: anywhere; + color: color-mix(in srgb, CanvasText 88%, transparent); font-size: 0.8125rem; } -.i18n-message__catalog { - color: var(--i18n-muted); - font-size: 0.75rem; +.i18n-message__content { + display: grid; + grid-template-columns: minmax(10rem, 0.7fr) minmax(14rem, 1.3fr); + gap: 0.75rem; + align-items: start; +} + +.i18n-message__source { + min-width: 0; + padding: 0.45rem 0.25rem; } .i18n-message__source p { - margin: 0.25rem 0 0; + margin: 0.3rem 0 0; + overflow-wrap: anywhere; line-height: 1.5; } @@ -141,6 +812,9 @@ } .i18n-message__label { + display: inline-flex; + align-items: baseline; + gap: 0.4rem; color: var(--i18n-muted); font-size: 0.75rem; font-weight: 600; @@ -148,24 +822,65 @@ text-transform: uppercase; } +.i18n-message__label code { + padding: 0.05rem 0.3rem; + border-radius: 0.25rem; + background: color-mix(in srgb, Canvas 80%, currentColor 20%); + color: color-mix(in srgb, CanvasText 76%, transparent); + font-size: 0.6875rem; + font-weight: 500; + letter-spacing: normal; + text-transform: none; +} + .i18n-message__meta { + min-width: 0; flex-wrap: wrap; color: var(--i18n-muted); font-size: 0.75rem; } +.i18n-message__meta span { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .i18n-message__actions { justify-content: flex-end; + min-height: 2.25rem; } .i18n-message__status { margin-inline-end: auto; color: var(--i18n-muted); + display: inline-flex; + align-items: center; + gap: 0.35rem; font-size: 0.75rem; } +.i18n-message__status[data-state="saved"] { + color: #16a34a; +} + +.i18n-message__status[data-state="error"] { + color: #dc2626; +} + +.i18n-message__status svg { + width: 0.875rem; + height: 0.875rem; +} + .i18n-message-panel button { + display: inline-flex; min-height: 2.25rem; + align-items: center; + justify-content: center; + gap: 0.4rem; padding: 0 0.8rem; border: 1px solid var(--i18n-border); border-radius: 0.55rem; @@ -173,10 +888,29 @@ cursor: pointer; } +.i18n-message-panel button svg { + width: 0.95rem; + height: 0.95rem; +} + .i18n-message-panel button:hover:not(:disabled) { background: color-mix(in srgb, Canvas 90%, currentColor 10%); } +.i18n-message-panel button.i18n-message__save { + border-color: var(--i18n-devtool-primary, #2563eb); + background: var(--i18n-devtool-primary, #2563eb); + color: var(--i18n-devtool-panel, #fff); +} + +.i18n-message-panel button.i18n-message__save:hover:not(:disabled) { + background: color-mix( + in srgb, + var(--i18n-devtool-primary, #2563eb) 86%, + black + ); +} + .i18n-message-panel button:disabled { cursor: default; opacity: 0.45; @@ -215,4 +949,33 @@ .i18n-message-panel__search { flex-basis: 100%; } + + .i18n-message-package__counts span { + padding-inline: 0.35rem; + } + + .i18n-message__content { + grid-template-columns: minmax(0, 1fr); + gap: 0.5rem; + } + + .i18n-message__source { + padding-bottom: 0.1rem; + } +} + +@media (max-width: 52rem) { + .i18n-message-workspace { + grid-template-columns: minmax(0, 1fr); + } + + .i18n-message-navigation { + max-height: 18rem; + border-right: 0; + border-bottom: 1px solid var(--i18n-border); + } + + .i18n-message-workspace__editor { + min-height: 20rem; + } } diff --git a/packages/i18n/src/devtool/message-panel.test.tsx b/packages/i18n/src/devtool/message-panel.test.tsx index 06d816c..885a396 100644 --- a/packages/i18n/src/devtool/message-panel.test.tsx +++ b/packages/i18n/src/devtool/message-panel.test.tsx @@ -1,8 +1,15 @@ // @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 { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" import { MessagePanel } from "./message-panel" import type { DevtoolMessage, MessageRepository } from "./types" @@ -10,14 +17,23 @@ import type { DevtoolMessage, MessageRepository } from "./types" const message: DevtoolMessage = { catalog: "messages", comments: ["Navigation title"], + editable: true, id: "navigation.home", missing: true, obsolete: false, origins: [{ file: "src/navigation.tsx", line: 12 }], + packageName: "@workspace/ui", source: "Home", + sourceLocale: "en", translation: "", } +afterEach(() => { + cleanup() + vi.restoreAllMocks() + Reflect.deleteProperty(window.navigator, "clipboard") +}) + describe("MessagePanel", () => { it("loads, filters, and saves messages through the repository", async () => { const updateMessage = vi.fn(async (input) => ({ @@ -33,10 +49,13 @@ describe("MessagePanel", () => { render() expect(await screen.findByText("navigation.home")).toBeTruthy() + expect(screen.getByRole("region", { name: "@workspace/ui" })).toBeTruthy() const textarea = screen.getByLabelText( "navigation.home translation for zh-Hans" ) + expect(screen.queryByRole("button", { name: "Save" })).toBeNull() + fireEvent.change(textarea, { target: { value: "首页" } }) fireEvent.click(screen.getByRole("button", { name: "Save" })) @@ -50,11 +69,323 @@ describe("MessagePanel", () => { }) expect(await screen.findByText("Saved")).toBeTruthy() + const missingOnly = screen.getByRole("checkbox", { + name: "Missing only", + }) + const checkboxIcon = missingOnly.nextElementSibling + + expect(checkboxIcon?.querySelector("svg")).toBeTruthy() + fireEvent.click(missingOnly) + expect(missingOnly).toHaveProperty("checked", true) + fireEvent.change(screen.getByPlaceholderText("Search messages"), { target: { value: "does not exist" }, }) + expect(screen.getByText("No messages match your search.")).toBeTruthy() + }) + + it("keeps the current content visible during a background refresh", async () => { + let resolveRefresh: + ((messages: readonly DevtoolMessage[]) => void) | undefined + const repository: MessageRepository = { + getMessages: vi + .fn() + .mockResolvedValueOnce([message]) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve + }) + ), + updateMessage: vi.fn(), + } + const view = render( + + ) + + expect(await screen.findByText("navigation.home")).toBeTruthy() + fireEvent.change(screen.getByPlaceholderText("Search messages"), { + target: { value: "home" }, + }) + + view.rerender( + + ) + + expect(screen.queryByText("Loading zh-Hans…")).toBeNull() + expect(screen.getByText("navigation.home")).toBeTruthy() + expect(screen.getByPlaceholderText("Search messages")).toHaveProperty( + "value", + "home" + ) + + await act(async () => { + resolveRefresh?.([]) + }) + expect( - screen.getByText("No messages match the current filters.") + await screen.findByText( + "No messages are available yet. Extract the project to populate this catalog." + ) ).toBeTruthy() }) + + it("collapses a package without discarding an unsaved translation", async () => { + const repository: MessageRepository = { + getMessages: vi.fn(async () => [message]), + updateMessage: vi.fn(), + } + + render() + + const textarea = await screen.findByLabelText( + "navigation.home translation for zh-Hans" + ) + fireEvent.change(textarea, { target: { value: "未保存的首页" } }) + + const collapseButton = screen.getByRole("button", { + name: "Collapse @workspace/ui", + }) + const packageContent = screen + .getByRole("region", { name: "@workspace/ui" }) + .querySelector(".i18n-message-package__messages") + fireEvent.click(collapseButton) + + expect(collapseButton.getAttribute("aria-expanded")).toBe("false") + expect(textarea).toHaveProperty("value", "未保存的首页") + expect(packageContent).toHaveProperty("hidden", true) + + fireEvent.click( + screen.getByRole("button", { + name: "Expand @workspace/ui", + }) + ) + + expect(textarea).toHaveProperty("value", "未保存的首页") + expect(packageContent).toHaveProperty("hidden", false) + }) + + it("shows the application package even when it has no messages", async () => { + const repository: MessageRepository = { + getMessages: vi.fn(async () => [message]), + getPackages: vi.fn(async () => [ + { + kind: "application" as const, + name: "web", + }, + ]), + updateMessage: vi.fn(), + } + + render() + + expect(await screen.findByRole("region", { name: "web" })).toBeTruthy() + expect( + screen + .getAllByRole("region") + .map((region) => region.getAttribute("aria-label")) + .filter((label) => label !== "Message editor") + ).toEqual(["web", "@workspace/ui"]) + expect( + screen.getByText("No messages were found in this package.") + ).toBeTruthy() + expect(screen.getByRole("region", { name: "web" }).textContent).toContain( + "0 messages" + ) + }) + + it("builds an ID tree and preserves visited editor drafts", async () => { + const activeMessage: DevtoolMessage = { + ...message, + id: "blocks.appearance.active", + source: "Active", + } + const darkMessage: DevtoolMessage = { + ...message, + id: "blocks.theme.dark", + source: "Dark", + } + const repository: MessageRepository = { + getMessages: vi.fn(async () => [activeMessage, darkMessage]), + updateMessage: vi.fn(), + } + + render() + + const activeEditor = await screen.findByLabelText( + "blocks.appearance.active translation for zh-Hans" + ) + + expect(screen.getByTitle("blocks")).toBeTruthy() + expect(screen.getByTitle("blocks.appearance")).toBeTruthy() + expect(screen.getByTitle("blocks.theme")).toBeTruthy() + expect(screen.queryByTitle("blocks.appearance.active")).toBeNull() + expect(screen.queryByTitle("blocks.theme.dark")).toBeNull() + fireEvent.change(activeEditor, { target: { value: "使用中" } }) + fireEvent.click(screen.getByTitle("blocks.theme")) + + expect( + await screen.findByLabelText("blocks.theme.dark translation for zh-Hans") + ).toBeTruthy() + + fireEvent.click(screen.getByTitle("blocks.appearance")) + expect( + await screen.findByLabelText( + "blocks.appearance.active translation for zh-Hans" + ) + ).toHaveProperty("value", "使用中") + }) + + it("uses the final ID segment as right-pane content instead of a tree leaf", async () => { + const parentMessage: DevtoolMessage = { + ...message, + id: "a.b", + source: "B", + } + const childMessage: DevtoolMessage = { + ...message, + id: "a.b.c", + source: "C", + } + const repository: MessageRepository = { + getMessages: vi.fn(async () => [parentMessage, childMessage]), + updateMessage: vi.fn(), + } + + render() + + expect( + await screen.findByLabelText("a.b translation for zh-Hans") + ).toBeTruthy() + expect(screen.getByTitle("a")).toBeTruthy() + expect(screen.getByTitle("a.b")).toBeTruthy() + expect(screen.queryByTitle("a.b.c")).toBeNull() + expect(screen.queryByLabelText("a.b.c translation for zh-Hans")).toBeNull() + + fireEvent.click(screen.getByTitle("a.b")) + + expect( + await screen.findByLabelText("a.b.c translation for zh-Hans") + ).toBeTruthy() + }) + + it("resets an unsaved translation to the repository value", async () => { + const updateMessage = vi.fn() + const repository: MessageRepository = { + getMessages: vi.fn(async () => [message]), + updateMessage, + } + + render() + + const textarea = await screen.findByLabelText( + "navigation.home translation for zh-Hans" + ) + + expect(screen.getByText("src/navigation.tsx:12")).toBeTruthy() + fireEvent.change(textarea, { target: { value: "未保存的首页" } }) + fireEvent.click(screen.getByRole("button", { name: "Reset" })) + + expect(textarea).toHaveProperty("value", "") + expect(screen.queryByRole("button", { name: "Save" })).toBeNull() + expect(updateMessage).not.toHaveBeenCalled() + }) + + it("keeps application source messages read-only", async () => { + const updateMessage = vi.fn() + const repository: MessageRepository = { + getMessages: vi.fn(async () => [ + { + ...message, + editable: false, + missing: false, + packageName: "test-app", + translation: "Home", + }, + ]), + getPackages: vi.fn(async () => [ + { kind: "application" as const, name: "test-app" }, + ]), + updateMessage, + } + + render() + + const textarea = await screen.findByLabelText( + "navigation.home translation for en" + ) + + expect(textarea).toHaveProperty("readOnly", true) + fireEvent.change(textarea, { target: { value: "Customized home" } }) + + expect(textarea).toHaveProperty("value", "Home") + expect(screen.queryByRole("button", { name: "Save" })).toBeNull() + expect(updateMessage).not.toHaveBeenCalled() + }) + + it("copies an explicitly selected message value", async () => { + const writeText = vi.fn(async () => undefined) + Object.defineProperty(window.navigator, "clipboard", { + configurable: true, + value: { writeText }, + }) + const repository: MessageRepository = { + getMessages: vi.fn(async () => [message]), + updateMessage: vi.fn(), + } + + render() + + await screen.findByText("navigation.home") + fireEvent.click( + screen.getByRole("button", { + name: "Choose content to copy", + }) + ) + + expect( + screen.getByRole("menuitem", { name: "Copy current translation" }) + ).toHaveProperty("disabled", true) + expect( + screen.getByRole("menuitem", { + name: /Copy source location/, + }) + ).toBeTruthy() + + fireEvent.click( + screen.getByRole("menuitem", { + name: "Copy message ID", + }) + ) + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith("navigation.home") + }) + expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy() + expect(screen.queryByRole("menu")).toBeNull() + expect(screen.getByRole("status").textContent).toContain("Copied") + }) + + it("distinguishes an empty catalog from a fully translated catalog", async () => { + const translatedMessage = { + ...message, + missing: false, + translation: "首页", + } + const repository: MessageRepository = { + getMessages: vi.fn(async () => [translatedMessage]), + updateMessage: vi.fn(), + } + + render() + + expect(await screen.findByText("navigation.home")).toBeTruthy() + fireEvent.click( + screen.getByRole("checkbox", { + name: "Missing only", + }) + ) + + expect(screen.getByText("All messages have translations.")).toBeTruthy() + }) }) diff --git a/packages/i18n/src/devtool/message-panel.tsx b/packages/i18n/src/devtool/message-panel.tsx index b5afc2d..25940ad 100644 --- a/packages/i18n/src/devtool/message-panel.tsx +++ b/packages/i18n/src/devtool/message-panel.tsx @@ -1,17 +1,40 @@ import * as React from "react" +import { + CircleAlertIcon, + CheckIcon, + FileCode2Icon, + RotateCcwIcon, + SaveIcon, + SearchIcon, +} from "lucide-react" +import { useDevtoolLocalization } from "./devtool-localization" +import { formatMessageOrigin, MessageCopyMenu } from "./message-copy-menu" +import { + createMessageNavigationModel, + MessageNavigation, +} from "./message-navigation" import { useOptionalMessageRepository } from "./message-repository" -import type { DevtoolMessage, MessageRepository } from "./types" +import type { + DevtoolMessage, + DevtoolMessagePackage, + MessageRepository, +} from "./types" import "./message-panel.css" type LoadState = | { status: "loading" } | { error: Error; status: "error" } - | { messages: readonly DevtoolMessage[]; status: "ready" } + | { + messages: readonly DevtoolMessage[] + packages: readonly DevtoolMessagePackage[] + status: "ready" + } export interface MessagePanelProps { className?: string locale: string + refreshToken?: unknown repository?: MessageRepository } @@ -20,6 +43,7 @@ function getMessageSearchText(message: DevtoolMessage) { message.id, message.source, message.translation, + message.packageName, ...message.comments, ...message.origins.map((origin) => origin.file), ] @@ -36,14 +60,23 @@ function MessageEditor({ message: DevtoolMessage onSave: (translation: string) => Promise }) { + const { messages } = useDevtoolLocalization() const [translation, setTranslation] = React.useState(message.translation) const [saveState, setSaveState] = React.useState< "idle" | "saving" | "saved" | "error" >("idle") + const repositoryTranslationRef = React.useRef(message.translation) const submittedTranslationRef = React.useRef(undefined) + const isEditable = message.editable const isDirty = translation !== message.translation + const primaryOrigin = message.origins[0] React.useEffect(() => { + if (repositoryTranslationRef.current === message.translation) { + return + } + + repositoryTranslationRef.current = message.translation setTranslation(message.translation) if (submittedTranslationRef.current === message.translation) { @@ -54,8 +87,17 @@ function MessageEditor({ setSaveState("idle") }, [message.translation]) + React.useEffect(() => { + if (saveState !== "saved") { + return + } + + const timeout = window.setTimeout(() => setSaveState("idle"), 1600) + return () => window.clearTimeout(timeout) + }, [saveState]) + const handleSave = async () => { - if (!isDirty || saveState === "saving") { + if (!isEditable || !isDirty || saveState === "saving") { return } @@ -71,69 +113,152 @@ function MessageEditor({ } } + const handleReset = () => { + submittedTranslationRef.current = undefined + setTranslation(message.translation) + setSaveState("idle") + } + return (
    -
    - {message.id} - {message.catalog} +
    +
    +
    +
    -
    - Source -

    {message.source || message.id}

    +
    + {message.id}
    -