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
This commit is contained in:
@@ -19,6 +19,9 @@ dist-ssr
|
|||||||
# turbo
|
# turbo
|
||||||
.turbo
|
.turbo
|
||||||
|
|
||||||
|
# generated i18n build schema
|
||||||
|
.i18n
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
+103
-23
@@ -1,8 +1,9 @@
|
|||||||
# @workspace/i18n
|
# @workspace/i18n
|
||||||
|
|
||||||
Reusable Lingui runtime, Devtool, Vite plugin, and CLI. This package does not own
|
Reusable Lingui runtime, Devtool, Vite plugin, and CLI. Every consuming
|
||||||
application locales or catalogs: every consuming application keeps its own
|
application owns its `i18n.config.json` and application catalogs. Reusable
|
||||||
`i18n.config.json`, `lingui.config.ts`, and generated message files.
|
packages may additionally ship built-in catalogs, which the application can
|
||||||
|
compose and override.
|
||||||
|
|
||||||
## Consumer setup
|
## Consumer setup
|
||||||
|
|
||||||
@@ -30,19 +31,29 @@ Create `i18n.config.json` in that application:
|
|||||||
"sourceLocale": "en",
|
"sourceLocale": "en",
|
||||||
"locales": ["en", "zh-Hans"],
|
"locales": ["en", "zh-Hans"],
|
||||||
"catalogPath": "src/locales/{locale}/messages",
|
"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}"]
|
"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
|
`catalogSources` lists reusable catalogs in merge order. The application
|
||||||
import project from "./i18n.config.json" with { type: "json" }
|
catalog from `catalogPath` is always merged last, so translations customized
|
||||||
import { createLinguiConfig } from "@workspace/i18n/lingui-config"
|
in the application override package defaults. Keep dependency source folders
|
||||||
|
in `include` when the application should be able to edit those overrides in
|
||||||
export default createLinguiConfig(project)
|
Message Studio.
|
||||||
```
|
|
||||||
|
|
||||||
Mount the API plugin in the consuming application's Vite configuration:
|
Mount the API plugin in the consuming application's Vite configuration:
|
||||||
|
|
||||||
@@ -57,12 +68,14 @@ export default defineConfig({
|
|||||||
|
|
||||||
## Runtime
|
## Runtime
|
||||||
|
|
||||||
Compiled Lingui catalogs are passed to the provider synchronously, so the
|
The Vite plugin exposes the merged catalog through
|
||||||
first client render and SSR render use the same locale.
|
`@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
|
```tsx
|
||||||
import { messages as en } from "@/locales/en/messages"
|
import { use } from "react"
|
||||||
import { messages as zhHans } from "@/locales/zh-Hans/messages"
|
import { loadMessageCatalog } from "@workspace/i18n/catalogs"
|
||||||
import {
|
import {
|
||||||
I18nProvider,
|
I18nProvider,
|
||||||
Translate,
|
Translate,
|
||||||
@@ -73,7 +86,12 @@ import {
|
|||||||
} from "@workspace/i18n"
|
} from "@workspace/i18n"
|
||||||
import { I18nDevtool } from "@workspace/i18n/devtool"
|
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 (
|
return (
|
||||||
<I18nProvider
|
<I18nProvider
|
||||||
locale={locale}
|
locale={locale}
|
||||||
@@ -81,7 +99,7 @@ function Root({ children, locale }: React.PropsWithChildren<{ locale: string }>)
|
|||||||
{ locale: "en", label: "English" },
|
{ locale: "en", label: "English" },
|
||||||
{ locale: "zh-Hans", label: "简体中文" },
|
{ locale: "zh-Hans", label: "简体中文" },
|
||||||
]}
|
]}
|
||||||
catalogs={{ en, "zh-Hans": zhHans }}
|
catalogs={{ [locale]: messages }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{import.meta.env.DEV && <I18nDevtool dark={"ui\\:dark"} />}
|
{import.meta.env.DEV && <I18nDevtool dark={"ui\\:dark"} />}
|
||||||
@@ -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/<locale>-<content-hash>.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
|
`Translate` is a thin wrapper around Lingui's runtime `Trans` component. It
|
||||||
does not add a DOM wrapper and retains `values`, `components`, `formats`,
|
does not add a DOM wrapper and retains `values`, `components`, `formats`,
|
||||||
`component`, and `render`.
|
`component`, and `render`.
|
||||||
|
|
||||||
The generated Lingui configuration points `runtimeConfigModule.Trans` at
|
The generated in-memory Lingui configuration points
|
||||||
`Translate`, so explicit `<Translate id="…" message="…" />` usages are found
|
`runtimeConfigModule.Trans` at `Translate`, so explicit
|
||||||
by `lingui extract`.
|
`<Translate id="…" message="…" />` usages are found by `lingui extract`.
|
||||||
|
|
||||||
## Devtool
|
## 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
|
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
|
such as `[data-theme="dark"]`. The Devtool observes document theme changes and
|
||||||
renders through a portal, so application overflow and stacking contexts do not
|
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
|
||||||
|
<I18nDevtool locale="zh-Hans" dark={"ui\\:dark"} />
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
Lower-level `MessagePanel`, `MessageRepositoryProvider`, and repository types
|
||||||
remain available from `@workspace/i18n/devtool`.
|
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
|
## CLI
|
||||||
|
|
||||||
Run inside the consuming application so the CLI discovers that application's
|
Run inside the consuming application so the CLI discovers that application's
|
||||||
@@ -168,5 +247,6 @@ bun run i18n:extract
|
|||||||
bun run i18n:compile
|
bun run i18n:compile
|
||||||
```
|
```
|
||||||
|
|
||||||
The application's `i18n.config.json` is the machine-editable source of truth.
|
The application's `i18n.config.json` is the single machine-editable source of
|
||||||
Its `lingui.config.ts` maps that manifest to Lingui's configuration format.
|
truth. Runtime catalog access, Devtool actions, extraction, and compilation all
|
||||||
|
derive their Lingui configuration from it.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
"./catalogs": "./src/runtime/catalog-loader.ts",
|
||||||
"./devtool": "./src/devtool/index.ts",
|
"./devtool": "./src/devtool/index.ts",
|
||||||
"./lingui-config": "./src/config/lingui-config.ts",
|
"./lingui-config": "./src/config/lingui-config.ts",
|
||||||
"./vite": "./src/vite.ts"
|
"./vite": "./src/vite.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",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, readonly string[]> = {}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("导航")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<Record<string, string>>
|
||||||
|
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<string, string> = {}
|
||||||
|
const idsByHash = new Map<string, string>()
|
||||||
|
|
||||||
|
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),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,12 +3,8 @@ import type { ViteDevServer } from "vite"
|
|||||||
|
|
||||||
import type { UpdateMessageInput } from "../devtool/types"
|
import type { UpdateMessageInput } from "../devtool/types"
|
||||||
import { CatalogService } from "./catalog-service.ts"
|
import { CatalogService } from "./catalog-service.ts"
|
||||||
import { runLinguiCommand } from "./lingui-command.ts"
|
import { runProjectLinguiCommand } from "./lingui-command.ts"
|
||||||
import {
|
import { readProject } from "./project.ts"
|
||||||
getLinguiConfigFilename,
|
|
||||||
getProjectRoot,
|
|
||||||
readProject,
|
|
||||||
} from "./project.ts"
|
|
||||||
|
|
||||||
const API_PREFIX = "/__i18n"
|
const API_PREFIX = "/__i18n"
|
||||||
|
|
||||||
@@ -86,6 +82,15 @@ export function configureI18nApi(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
request.method === "GET" &&
|
||||||
|
url.pathname === `${API_PREFIX}/packages`
|
||||||
|
) {
|
||||||
|
const service = new CatalogService(projectFilename)
|
||||||
|
sendJson(response, 200, await service.getPackages())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
request.method === "PUT" &&
|
request.method === "PUT" &&
|
||||||
url.pathname === `${API_PREFIX}/messages`
|
url.pathname === `${API_PREFIX}/messages`
|
||||||
@@ -115,15 +120,7 @@ export function configureI18nApi(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await runLinguiCommand(
|
const result = await runProjectLinguiCommand(projectFilename, action)
|
||||||
[
|
|
||||||
action,
|
|
||||||
...(action === "compile" ? ["--typescript"] : []),
|
|
||||||
"--config",
|
|
||||||
getLinguiConfigFilename(projectFilename),
|
|
||||||
],
|
|
||||||
getProjectRoot(projectFilename)
|
|
||||||
)
|
|
||||||
sendJson(response, 200, result)
|
sendJson(response, 200, result)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { join } from "node:path"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
|
|
||||||
import { getCatalogs } from "@lingui/cli/api"
|
import { getCatalogs } from "@lingui/cli/api"
|
||||||
import { getConfig } from "@lingui/conf"
|
|
||||||
import { afterEach, describe, expect, it } from "vitest"
|
import { afterEach, describe, expect, it } from "vitest"
|
||||||
|
|
||||||
import { CatalogService } from "./catalog-service"
|
import { CatalogService } from "./catalog-service"
|
||||||
|
import { loadProjectLinguiConfig } from "./project"
|
||||||
|
|
||||||
const temporaryDirectories: string[] = []
|
const temporaryDirectories: string[] = []
|
||||||
|
|
||||||
@@ -23,28 +23,31 @@ describe("CatalogService", () => {
|
|||||||
const directory = await mkdtemp(join(tmpdir(), "workspace-i18n-"))
|
const directory = await mkdtemp(join(tmpdir(), "workspace-i18n-"))
|
||||||
temporaryDirectories.push(directory)
|
temporaryDirectories.push(directory)
|
||||||
const projectFilename = join(directory, "i18n.config.json")
|
const projectFilename = join(directory, "i18n.config.json")
|
||||||
const linguiConfigFilename = join(directory, "lingui.config.ts")
|
|
||||||
|
|
||||||
await writeFile(projectFilename, "{}\n")
|
|
||||||
await writeFile(
|
await writeFile(
|
||||||
linguiConfigFilename,
|
join(directory, "package.json"),
|
||||||
[
|
`${JSON.stringify({ name: "test-app", private: true }, null, 2)}\n`
|
||||||
"export default {",
|
)
|
||||||
' sourceLocale: "en",',
|
await mkdir(join(directory, "packages/ui"), { recursive: true })
|
||||||
' locales: ["en", "zh-Hans"],',
|
await writeFile(
|
||||||
" catalogs: [{",
|
join(directory, "packages/ui/package.json"),
|
||||||
' path: "<rootDir>/locales/{locale}/messages",',
|
`${JSON.stringify({ name: "@workspace/ui", private: true }, null, 2)}\n`
|
||||||
' include: ["<rootDir>/src"],',
|
)
|
||||||
" }],",
|
await writeFile(
|
||||||
"}",
|
projectFilename,
|
||||||
"",
|
`${JSON.stringify(
|
||||||
].join("\n")
|
{
|
||||||
|
catalogPath: "locales/{locale}/messages",
|
||||||
|
include: ["src"],
|
||||||
|
locales: ["en", "zh-Hans"],
|
||||||
|
sourceLocale: "en",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`
|
||||||
)
|
)
|
||||||
|
|
||||||
const config = getConfig({
|
const config = await loadProjectLinguiConfig(projectFilename)
|
||||||
configPath: linguiConfigFilename,
|
|
||||||
cwd: directory,
|
|
||||||
})
|
|
||||||
const [catalog] = await getCatalogs(config)
|
const [catalog] = await getCatalogs(config)
|
||||||
|
|
||||||
if (!catalog) {
|
if (!catalog) {
|
||||||
@@ -58,6 +61,11 @@ describe("CatalogService", () => {
|
|||||||
origin: [["src/dashboard.tsx", 10]],
|
origin: [["src/dashboard.tsx", 10]],
|
||||||
translation: "Dashboard",
|
translation: "Dashboard",
|
||||||
},
|
},
|
||||||
|
"ui.common.close": {
|
||||||
|
message: "Close",
|
||||||
|
origin: [["packages/ui/src/dialog.tsx", 12]],
|
||||||
|
translation: "Close",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
await catalog.write("zh-Hans", {
|
await catalog.write("zh-Hans", {
|
||||||
"dashboard.title": {
|
"dashboard.title": {
|
||||||
@@ -66,14 +74,26 @@ describe("CatalogService", () => {
|
|||||||
origin: [["src/dashboard.tsx", 10]],
|
origin: [["src/dashboard.tsx", 10]],
|
||||||
translation: "",
|
translation: "",
|
||||||
},
|
},
|
||||||
|
"ui.common.close": {
|
||||||
|
message: "Close",
|
||||||
|
origin: [["packages/ui/src/dialog.tsx", 12]],
|
||||||
|
translation: "关闭",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const service = new CatalogService(projectFilename)
|
const service = new CatalogService(projectFilename)
|
||||||
|
await expect(service.getPackages()).resolves.toEqual([
|
||||||
|
{
|
||||||
|
kind: "application",
|
||||||
|
name: "test-app",
|
||||||
|
},
|
||||||
|
])
|
||||||
const [message] = await service.getMessages("zh-Hans")
|
const [message] = await service.getMessages("zh-Hans")
|
||||||
|
|
||||||
expect(message).toMatchObject({
|
expect(message).toMatchObject({
|
||||||
id: "dashboard.title",
|
id: "dashboard.title",
|
||||||
missing: true,
|
missing: true,
|
||||||
|
packageName: "test-app",
|
||||||
source: "Dashboard",
|
source: "Dashboard",
|
||||||
translation: "",
|
translation: "",
|
||||||
})
|
})
|
||||||
@@ -92,5 +112,47 @@ describe("CatalogService", () => {
|
|||||||
expect(await readFile(catalog.getFilename("zh-Hans"), "utf8")).toContain(
|
expect(await readFile(catalog.getFilename("zh-Hans"), "utf8")).toContain(
|
||||||
'msgstr "数据看板"'
|
'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"'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,16 +1,98 @@
|
|||||||
import { getCatalogs } from "@lingui/cli/api"
|
import { readFile } from "node:fs/promises"
|
||||||
import type { CatalogType, MessageType } from "@lingui/conf"
|
import { dirname, join, resolve } from "node:path"
|
||||||
import { getConfig } from "@lingui/conf"
|
|
||||||
|
|
||||||
import type { DevtoolMessage, UpdateMessageInput } from "../devtool/types"
|
import { getCatalogs } from "@lingui/cli/api"
|
||||||
import { getLinguiConfigFilename, getProjectRoot } from "./project.ts"
|
import type {
|
||||||
|
CatalogType,
|
||||||
|
LinguiConfigNormalized,
|
||||||
|
MessageType,
|
||||||
|
} from "@lingui/conf"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DevtoolMessage,
|
||||||
|
DevtoolMessagePackage,
|
||||||
|
UpdateMessageInput,
|
||||||
|
} from "../devtool/types"
|
||||||
|
import { getProjectRoot, loadProjectLinguiConfig } from "./project.ts"
|
||||||
|
|
||||||
interface CatalogEntry {
|
interface CatalogEntry {
|
||||||
catalog: Awaited<ReturnType<typeof getCatalogs>>[number]
|
catalog: Awaited<ReturnType<typeof getCatalogs>>[number]
|
||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCatalogEntries(config: ReturnType<typeof getConfig>) {
|
interface PackageManifest {
|
||||||
|
name?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
class PackageNameResolver {
|
||||||
|
readonly #directoryCache = new Map<string, Promise<string | undefined>>()
|
||||||
|
readonly #projectRoot: string
|
||||||
|
readonly #projectPackageName: Promise<string>
|
||||||
|
|
||||||
|
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<string | undefined> {
|
||||||
|
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<string | undefined> {
|
||||||
|
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)
|
const catalogs = await getCatalogs(config)
|
||||||
|
|
||||||
return catalogs.map<CatalogEntry>((catalog, index) => ({
|
return catalogs.map<CatalogEntry>((catalog, index) => ({
|
||||||
@@ -20,16 +102,20 @@ async function getCatalogEntries(config: ReturnType<typeof getConfig>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toDevtoolMessage({
|
function toDevtoolMessage({
|
||||||
|
applicationPackageName,
|
||||||
catalog,
|
catalog,
|
||||||
id,
|
id,
|
||||||
locale,
|
locale,
|
||||||
|
packageName,
|
||||||
sourceEntry,
|
sourceEntry,
|
||||||
sourceLocale,
|
sourceLocale,
|
||||||
targetEntry,
|
targetEntry,
|
||||||
}: {
|
}: {
|
||||||
|
applicationPackageName: string
|
||||||
catalog: string
|
catalog: string
|
||||||
id: string
|
id: string
|
||||||
locale: string
|
locale: string
|
||||||
|
packageName: string
|
||||||
sourceEntry: MessageType | undefined
|
sourceEntry: MessageType | undefined
|
||||||
sourceLocale: string
|
sourceLocale: string
|
||||||
targetEntry: MessageType | undefined
|
targetEntry: MessageType | undefined
|
||||||
@@ -48,37 +134,52 @@ function toDevtoolMessage({
|
|||||||
return {
|
return {
|
||||||
catalog,
|
catalog,
|
||||||
comments: targetEntry?.comments ?? sourceEntry?.comments ?? [],
|
comments: targetEntry?.comments ?? sourceEntry?.comments ?? [],
|
||||||
|
editable: locale !== sourceLocale || packageName !== applicationPackageName,
|
||||||
id,
|
id,
|
||||||
missing: locale !== sourceLocale && translation.length === 0,
|
missing: locale !== sourceLocale && translation.length === 0,
|
||||||
obsolete: Boolean(targetEntry?.obsolete ?? sourceEntry?.obsolete),
|
obsolete: Boolean(targetEntry?.obsolete ?? sourceEntry?.obsolete),
|
||||||
origins: (sourceEntry?.origin ?? targetEntry?.origin ?? []).map(
|
origins: (sourceEntry?.origin ?? targetEntry?.origin ?? []).map(
|
||||||
([file, line]) => ({ file, line })
|
([file, line]) => ({ file, line })
|
||||||
),
|
),
|
||||||
|
packageName,
|
||||||
source,
|
source,
|
||||||
|
sourceLocale,
|
||||||
translation,
|
translation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CatalogService {
|
export class CatalogService {
|
||||||
readonly config: ReturnType<typeof getConfig>
|
readonly context: Promise<{
|
||||||
readonly catalogs: Promise<readonly CatalogEntry[]>
|
catalogs: readonly CatalogEntry[]
|
||||||
|
config: Awaited<ReturnType<typeof loadProjectLinguiConfig>>
|
||||||
|
packageNameResolver: PackageNameResolver
|
||||||
|
}>
|
||||||
|
|
||||||
constructor(projectFilename: string) {
|
constructor(projectFilename: string) {
|
||||||
this.config = getConfig({
|
const packageNameResolver = new PackageNameResolver(
|
||||||
configPath: getLinguiConfigFilename(projectFilename),
|
getProjectRoot(projectFilename)
|
||||||
cwd: getProjectRoot(projectFilename),
|
)
|
||||||
})
|
|
||||||
this.catalogs = getCatalogEntries(this.config)
|
this.context = loadProjectLinguiConfig(projectFilename).then(
|
||||||
|
async (config) => ({
|
||||||
|
catalogs: await getCatalogEntries(config),
|
||||||
|
config,
|
||||||
|
packageNameResolver,
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMessages(locale: string) {
|
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(
|
const messages = await Promise.all(
|
||||||
catalogs.map(async ({ catalog, key }) => {
|
catalogs.map(async ({ catalog, key }) => {
|
||||||
const [sourceCatalog = {}, targetCatalog = {}] = await Promise.all([
|
const [sourceCatalog = {}, targetCatalog = {}] = await Promise.all([
|
||||||
catalog.read(this.config.sourceLocale),
|
catalog.read(config.sourceLocale),
|
||||||
catalog.read(locale),
|
catalog.read(locale),
|
||||||
])
|
])
|
||||||
const ids = new Set([
|
const ids = new Set([
|
||||||
@@ -86,28 +187,50 @@ export class CatalogService {
|
|||||||
...Object.keys(targetCatalog),
|
...Object.keys(targetCatalog),
|
||||||
])
|
])
|
||||||
|
|
||||||
return Array.from(ids)
|
return Promise.all(
|
||||||
.sort((left, right) => left.localeCompare(right))
|
Array.from(ids)
|
||||||
.map((id) =>
|
.sort((left, right) => left.localeCompare(right))
|
||||||
toDevtoolMessage({
|
.map(async (id) => {
|
||||||
catalog: key,
|
const sourceEntry = sourceCatalog[id]
|
||||||
id,
|
const targetEntry = targetCatalog[id]
|
||||||
locale,
|
const packageName = await packageNameResolver.resolve(
|
||||||
sourceEntry: sourceCatalog[id],
|
sourceEntry?.origin ?? targetEntry?.origin
|
||||||
sourceLocale: this.config.sourceLocale,
|
)
|
||||||
targetEntry: targetCatalog[id],
|
|
||||||
|
return toDevtoolMessage({
|
||||||
|
applicationPackageName,
|
||||||
|
catalog: key,
|
||||||
|
id,
|
||||||
|
locale,
|
||||||
|
packageName,
|
||||||
|
sourceEntry,
|
||||||
|
sourceLocale: config.sourceLocale,
|
||||||
|
targetEntry,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
return messages.flat()
|
return messages.flat()
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMessage(input: UpdateMessageInput) {
|
async getPackages(): Promise<readonly DevtoolMessagePackage[]> {
|
||||||
this.assertLocale(input.locale)
|
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)
|
const entry = catalogs.find((candidate) => candidate.key === input.catalog)
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
@@ -115,7 +238,7 @@ export class CatalogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [sourceCatalog = {}, targetCatalog = {}] = await Promise.all([
|
const [sourceCatalog = {}, targetCatalog = {}] = await Promise.all([
|
||||||
entry.catalog.read(this.config.sourceLocale),
|
entry.catalog.read(config.sourceLocale),
|
||||||
entry.catalog.read(input.locale),
|
entry.catalog.read(input.locale),
|
||||||
])
|
])
|
||||||
const sourceEntry = sourceCatalog[input.id]
|
const sourceEntry = sourceCatalog[input.id]
|
||||||
@@ -125,6 +248,21 @@ export class CatalogService {
|
|||||||
throw new Error(`Unknown message: ${input.id}`)
|
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 = {
|
const nextCatalog: CatalogType = {
|
||||||
...targetCatalog,
|
...targetCatalog,
|
||||||
[input.id]: {
|
[input.id]: {
|
||||||
@@ -137,17 +275,19 @@ export class CatalogService {
|
|||||||
await entry.catalog.write(input.locale, nextCatalog)
|
await entry.catalog.write(input.locale, nextCatalog)
|
||||||
|
|
||||||
return toDevtoolMessage({
|
return toDevtoolMessage({
|
||||||
|
applicationPackageName,
|
||||||
catalog: entry.key,
|
catalog: entry.key,
|
||||||
id: input.id,
|
id: input.id,
|
||||||
locale: input.locale,
|
locale: input.locale,
|
||||||
|
packageName,
|
||||||
sourceEntry,
|
sourceEntry,
|
||||||
sourceLocale: this.config.sourceLocale,
|
sourceLocale: config.sourceLocale,
|
||||||
targetEntry: nextCatalog[input.id],
|
targetEntry: nextCatalog[input.id],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertLocale(locale: string) {
|
private assertLocale(locales: readonly string[], locale: string) {
|
||||||
if (!this.config.locales.includes(locale)) {
|
if (!locales.includes(locale)) {
|
||||||
throw new Error(`Unknown locale: ${locale}`)
|
throw new Error(`Unknown locale: ${locale}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,9 @@
|
|||||||
import { cac } from "cac"
|
import { cac } from "cac"
|
||||||
|
|
||||||
import { startDevtool } from "./dev-server"
|
import { startDevtool } from "./dev-server"
|
||||||
import { runLinguiCommand } from "./lingui-command"
|
import { runProjectLinguiCommand } from "./lingui-command"
|
||||||
import { addLocale } from "./new-locale"
|
import { addLocale } from "./new-locale"
|
||||||
import {
|
import { resolveProjectFilename } from "./project"
|
||||||
getLinguiConfigFilename,
|
|
||||||
getProjectRoot,
|
|
||||||
resolveProjectFilename,
|
|
||||||
} from "./project"
|
|
||||||
|
|
||||||
const cli = cac("workspace-i18n")
|
const cli = cac("workspace-i18n")
|
||||||
|
|
||||||
@@ -18,15 +14,7 @@ async function runProjectCommand(
|
|||||||
projectOption?: string
|
projectOption?: string
|
||||||
) {
|
) {
|
||||||
const projectFilename = await resolveProjectFilename(projectOption)
|
const projectFilename = await resolveProjectFilename(projectOption)
|
||||||
const result = await runLinguiCommand(
|
const result = await runProjectLinguiCommand(projectFilename, command)
|
||||||
[
|
|
||||||
command,
|
|
||||||
...(command === "compile" ? ["--typescript"] : []),
|
|
||||||
"--config",
|
|
||||||
getLinguiConfigFilename(projectFilename),
|
|
||||||
],
|
|
||||||
getProjectRoot(projectFilename)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.output.trim()) {
|
if (result.output.trim()) {
|
||||||
process.stdout.write(result.output)
|
process.stdout.write(result.output)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
import { spawn } from "node:child_process"
|
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 { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
import { createLinguiConfig } from "../config/lingui-config.ts"
|
||||||
|
import { getProjectRoot, readProject } from "./project.ts"
|
||||||
|
|
||||||
const LINGUI_BIN = fileURLToPath(
|
const LINGUI_BIN = fileURLToPath(
|
||||||
new URL("./lingui.js", import.meta.resolve("@lingui/cli"))
|
new URL("./lingui.js", import.meta.resolve("@lingui/cli"))
|
||||||
)
|
)
|
||||||
@@ -9,12 +22,86 @@ export interface LinguiCommandResult {
|
|||||||
output: string
|
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("<rootDir>", 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[],
|
args: readonly string[],
|
||||||
cwd: string
|
cwd: string
|
||||||
): Promise<LinguiCommandResult> {
|
): Promise<LinguiCommandResult> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(process.execPath, [LINGUI_BIN, ...args], {
|
const child = spawn("node", [LINGUI_BIN, ...args], {
|
||||||
cwd,
|
cwd,
|
||||||
env: process.env,
|
env: process.env,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import {
|
import { readProject, resolveProjectFilename, writeProject } from "./project"
|
||||||
getLinguiConfigFilename,
|
import { runProjectLinguiCommand } from "./lingui-command"
|
||||||
getProjectRoot,
|
|
||||||
readProject,
|
|
||||||
resolveProjectFilename,
|
|
||||||
writeProject,
|
|
||||||
} from "./project"
|
|
||||||
import { runLinguiCommand } from "./lingui-command"
|
|
||||||
|
|
||||||
export interface AddLocaleOptions {
|
export interface AddLocaleOptions {
|
||||||
project?: string
|
project?: string
|
||||||
@@ -38,16 +32,9 @@ export async function addLocale(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runLinguiCommand(
|
const result = await runProjectLinguiCommand(projectFilename, "extract", {
|
||||||
[
|
locale,
|
||||||
"extract",
|
})
|
||||||
"--locale",
|
|
||||||
locale,
|
|
||||||
"--config",
|
|
||||||
getLinguiConfigFilename(projectFilename),
|
|
||||||
],
|
|
||||||
getProjectRoot(projectFilename)
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
created,
|
created,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { join } from "node:path"
|
|||||||
|
|
||||||
import { afterEach, describe, expect, it } from "vitest"
|
import { afterEach, describe, expect, it } from "vitest"
|
||||||
|
|
||||||
import { resolveProjectFilename } from "./project"
|
import { readProject, resolveProjectFilename } from "./project"
|
||||||
|
|
||||||
const temporaryDirectories: string[] = []
|
const temporaryDirectories: string[] = []
|
||||||
|
|
||||||
@@ -44,3 +44,34 @@ describe("resolveProjectFilename", () => {
|
|||||||
).rejects.toThrow("Run the command inside a configured application")
|
).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}",
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { access, readFile, writeFile } from "node:fs/promises"
|
|||||||
import { dirname, isAbsolute, join, resolve } from "node:path"
|
import { dirname, isAbsolute, join, resolve } from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
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 PACKAGE_ROOT = fileURLToPath(new URL("../../", import.meta.url))
|
||||||
const PROJECT_FILENAME = "i18n.config.json"
|
const PROJECT_FILENAME = "i18n.config.json"
|
||||||
@@ -31,6 +34,11 @@ function validateProjectConfig(
|
|||||||
!Array.isArray(value.locales) ||
|
!Array.isArray(value.locales) ||
|
||||||
!value.locales.every((locale) => typeof locale === "string") ||
|
!value.locales.every((locale) => typeof locale === "string") ||
|
||||||
typeof value.catalogPath !== "string" ||
|
typeof value.catalogPath !== "string" ||
|
||||||
|
(value.catalogSources !== undefined &&
|
||||||
|
(!Array.isArray(value.catalogSources) ||
|
||||||
|
!value.catalogSources.every(
|
||||||
|
(catalogSource) => typeof catalogSource === "string"
|
||||||
|
))) ||
|
||||||
!Array.isArray(value.include) ||
|
!Array.isArray(value.include) ||
|
||||||
!value.include.every((pattern) => typeof pattern === "string") ||
|
!value.include.every((pattern) => typeof pattern === "string") ||
|
||||||
(value.exclude !== undefined &&
|
(value.exclude !== undefined &&
|
||||||
@@ -44,6 +52,7 @@ function validateProjectConfig(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
catalogPath: value.catalogPath,
|
catalogPath: value.catalogPath,
|
||||||
|
catalogSources: value.catalogSources as string[] | undefined,
|
||||||
exclude: value.exclude as string[] | undefined,
|
exclude: value.exclude as string[] | undefined,
|
||||||
include: value.include,
|
include: value.include,
|
||||||
locales: value.locales,
|
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) {
|
export function getProjectRoot(projectFilename: string) {
|
||||||
return dirname(projectFilename)
|
return dirname(projectFilename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadProjectLinguiConfig(projectFilename: string) {
|
||||||
|
return createNormalizedLinguiConfig(
|
||||||
|
await readProject(projectFilename),
|
||||||
|
getProjectRoot(projectFilename)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function getPackageRoot() {
|
export function getPackageRoot() {
|
||||||
return PACKAGE_ROOT
|
return PACKAGE_ROOT
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
import { createLinguiConfig } from "./lingui-config"
|
import {
|
||||||
|
createLinguiConfig,
|
||||||
|
createNormalizedLinguiConfig,
|
||||||
|
} from "./lingui-config"
|
||||||
|
|
||||||
describe("createLinguiConfig", () => {
|
describe("createLinguiConfig", () => {
|
||||||
it("maps the editable project manifest to a Lingui configuration", () => {
|
it("maps the editable project manifest to a Lingui configuration", () => {
|
||||||
expect(
|
expect(
|
||||||
createLinguiConfig({
|
createLinguiConfig({
|
||||||
catalogPath: "locales/{locale}/messages",
|
catalogPath: "locales/{locale}/messages",
|
||||||
|
catalogSources: ["@workspace/ui/locales/{locale}"],
|
||||||
exclude: ["**/*.test.ts"],
|
exclude: ["**/*.test.ts"],
|
||||||
include: ["src"],
|
include: ["src"],
|
||||||
locales: ["en", "zh-Hans"],
|
locales: ["en", "zh-Hans"],
|
||||||
@@ -27,4 +31,26 @@ describe("createLinguiConfig", () => {
|
|||||||
sourceLocale: "en",
|
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",
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { isAbsolute } from "node:path"
|
import { isAbsolute } from "node:path"
|
||||||
|
|
||||||
import type { LinguiConfig } from "@lingui/conf"
|
import {
|
||||||
|
makeConfig,
|
||||||
|
type LinguiConfig,
|
||||||
|
type LinguiConfigNormalized,
|
||||||
|
} from "@lingui/conf"
|
||||||
|
|
||||||
export interface I18nProjectConfig {
|
export interface I18nProjectConfig {
|
||||||
catalogPath: string
|
catalogPath: string
|
||||||
|
catalogSources?: readonly string[]
|
||||||
exclude?: readonly string[]
|
exclude?: readonly string[]
|
||||||
include: readonly string[]
|
include: readonly string[]
|
||||||
locales: readonly string[]
|
locales: readonly string[]
|
||||||
@@ -34,3 +39,13 @@ export function createLinguiConfig(project: I18nProjectConfig): LinguiConfig {
|
|||||||
sourceLocale: project.sourceLocale,
|
sourceLocale: project.sourceLocale,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createNormalizedLinguiConfig(
|
||||||
|
project: I18nProjectConfig,
|
||||||
|
rootDir: string
|
||||||
|
): LinguiConfigNormalized {
|
||||||
|
return makeConfig({
|
||||||
|
...createLinguiConfig(project),
|
||||||
|
rootDir,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
<code>{children}</code>
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
<AnsiText>{"\u001b[32mDone\u001b[39m"}</AnsiText>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(markup).toContain("Done")
|
||||||
|
expect(markup).toContain("color:")
|
||||||
|
expect(markup).not.toContain("\u001b")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<AnsiTextProps>
|
||||||
|
|
||||||
|
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 <AnsiComponent {...props} />
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
export type DevtoolAction = "compile" | "extract"
|
export type DevtoolAction = "compile" | "extract"
|
||||||
|
|
||||||
|
export interface DevtoolProject {
|
||||||
|
locales: readonly string[]
|
||||||
|
sourceLocale: string
|
||||||
|
}
|
||||||
|
|
||||||
interface ApiError {
|
interface ApiError {
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
@@ -29,3 +34,9 @@ export async function runDevtoolAction(
|
|||||||
{ method: "POST" }
|
{ method: "POST" }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function requestDevtoolProject(baseUrl = "/__i18n") {
|
||||||
|
const normalizedBaseUrl = baseUrl.replace(/\/+$/, "")
|
||||||
|
|
||||||
|
return requestDevtoolJson<DevtoolProject>(`${normalizedBaseUrl}/project`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<main className="devtool-app__state">
|
<main className="devtool-app__state">
|
||||||
Add a locale with <code>workspace-i18n new <locale></code>.
|
Add a target locale with <code>workspace-i18n new <locale></code>.
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<I18nProvider locale={locale} locales={state.project.locales}>
|
<I18nProvider locale={locale} locales={state.project.locales}>
|
||||||
<I18nDevtool defaultOpen mode="standalone" />
|
<I18nDevtool
|
||||||
|
defaultOpen
|
||||||
|
mode="standalone"
|
||||||
|
sourceLocale={state.project.sourceLocale}
|
||||||
|
/>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<section className="i18n-devtool__output" data-error={error || undefined}>
|
||||||
|
<header className="i18n-devtool__output-header">
|
||||||
|
<span>
|
||||||
|
<TerminalIcon aria-hidden="true" />
|
||||||
|
{messages.commandOutput.title}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-label={
|
||||||
|
expanded
|
||||||
|
? messages.commandOutput.collapse
|
||||||
|
: messages.commandOutput.expand
|
||||||
|
}
|
||||||
|
onClick={() => onExpandedChange(!expanded)}
|
||||||
|
>
|
||||||
|
<ToggleIcon aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={messages.commandOutput.close}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{expanded && (
|
||||||
|
<pre>
|
||||||
|
<AnsiText>{output}</AnsiText>
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
<div className="i18n-devtool__source-locale-field">
|
||||||
|
<span>{messages.locale.sourceLabel}</span>
|
||||||
|
<output className="i18n-devtool__source-locale">
|
||||||
|
{sourceLocaleLabel}
|
||||||
|
</output>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label className="i18n-devtool__locale-field">
|
||||||
|
<span>{messages.locale.label}</span>
|
||||||
|
<DevtoolSelect
|
||||||
|
aria-label={messages.locale.controlLabel}
|
||||||
|
className="i18n-devtool__locale-select"
|
||||||
|
disabled={locales.length === 0}
|
||||||
|
options={
|
||||||
|
locales.length === 0
|
||||||
|
? [{ label: messages.locale.noTargetLocales, value: "" }]
|
||||||
|
: locales.map((definition) => ({
|
||||||
|
label: definition.label ?? definition.locale,
|
||||||
|
value: definition.locale,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
value={locale}
|
||||||
|
onChange={(event) => onLocaleChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<I18nDevtoolLocale, DevtoolMessages> = {
|
||||||
|
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 (
|
||||||
|
<DevtoolLocalizationContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</DevtoolLocalizationContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDevtoolLocalization() {
|
||||||
|
return React.useContext(DevtoolLocalizationContext)
|
||||||
|
}
|
||||||
@@ -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<DevtoolPreferences>
|
||||||
|
|
||||||
|
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<DevtoolPreferences>(
|
||||||
|
() => {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<select className={resolvedClassName} {...props}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option
|
||||||
|
key={option.value}
|
||||||
|
disabled={option.disabled}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<I18nDevtoolLocale, string> = {
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<div className="i18n-devtool__settings" ref={rootRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-controls="i18n-devtool-settings-panel"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label={messages.settings.open}
|
||||||
|
className="i18n-devtool__settings-trigger"
|
||||||
|
onClick={() => setOpen((current) => !current)}
|
||||||
|
>
|
||||||
|
<SettingsIcon aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<section
|
||||||
|
id="i18n-devtool-settings-panel"
|
||||||
|
aria-label={messages.settings.label}
|
||||||
|
className="i18n-devtool__settings-panel"
|
||||||
|
>
|
||||||
|
<label className="i18n-devtool__settings-field">
|
||||||
|
<span>{messages.settings.interfaceLanguage}</span>
|
||||||
|
<DevtoolSelect
|
||||||
|
aria-label={messages.settings.interfaceLanguageControl}
|
||||||
|
options={I18N_DEVTOOL_LOCALES.map((locale) => ({
|
||||||
|
label: interfaceLocaleLabels[locale],
|
||||||
|
value: locale,
|
||||||
|
}))}
|
||||||
|
value={interfaceLocale}
|
||||||
|
onChange={(event) =>
|
||||||
|
onInterfaceLocaleChange(event.target.value as I18nDevtoolLocale)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="i18n-devtool__settings-field">
|
||||||
|
<span>{messages.settings.theme}</span>
|
||||||
|
<DevtoolThemeSwitcher theme={theme} onThemeChange={onThemeChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPanelPlacement && (
|
||||||
|
<div className="i18n-devtool__settings-field">
|
||||||
|
<span>{messages.settings.panelPosition}</span>
|
||||||
|
<div
|
||||||
|
aria-label={messages.settings.panelPositionControl}
|
||||||
|
className="i18n-devtool__settings-options"
|
||||||
|
role="group"
|
||||||
|
>
|
||||||
|
{panelPlacements.map(
|
||||||
|
({ icon: PlacementIcon, label, value }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={panelPlacement === value}
|
||||||
|
onClick={() => onPanelPlacementChange(value)}
|
||||||
|
>
|
||||||
|
<PlacementIcon aria-hidden="true" />
|
||||||
|
<span>{messages.settings[label]}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
aria-label={messages.settings.themeControl}
|
||||||
|
className="i18n-devtool__theme-switcher"
|
||||||
|
role="group"
|
||||||
|
>
|
||||||
|
{themes.map(({ accessibleLabel, icon: ThemeIcon, label, value }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
aria-label={messages.settings[accessibleLabel]}
|
||||||
|
aria-pressed={theme === value}
|
||||||
|
onClick={() => onThemeChange(value)}
|
||||||
|
>
|
||||||
|
<ThemeIcon aria-hidden="true" />
|
||||||
|
<span>{messages.settings[label]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
DevtoolMessage,
|
DevtoolMessage,
|
||||||
|
DevtoolMessagePackage,
|
||||||
MessageRepository,
|
MessageRepository,
|
||||||
UpdateMessageInput,
|
UpdateMessageInput,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
@@ -38,6 +39,11 @@ export function createHttpMessageRepository(
|
|||||||
|
|
||||||
return readResponse<readonly DevtoolMessage[]>(response)
|
return readResponse<readonly DevtoolMessage[]>(response)
|
||||||
},
|
},
|
||||||
|
async getPackages() {
|
||||||
|
const response = await fetch(`${normalizedBaseUrl}/packages`)
|
||||||
|
|
||||||
|
return readResponse<readonly DevtoolMessagePackage[]>(response)
|
||||||
|
},
|
||||||
async updateMessage(input: UpdateMessageInput) {
|
async updateMessage(input: UpdateMessageInput) {
|
||||||
const response = await fetch(`${normalizedBaseUrl}/messages`, {
|
const response = await fetch(`${normalizedBaseUrl}/messages`, {
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-controls={controls}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={messages.trigger.open}
|
||||||
|
className="i18n-devtool__trigger"
|
||||||
|
data-collapsed={floatingTrigger.isCollapsed || undefined}
|
||||||
|
data-dock={floatingTrigger.dockEdge}
|
||||||
|
data-dragging={floatingTrigger.isDragging || undefined}
|
||||||
|
hidden={open}
|
||||||
|
ref={floatingTrigger.triggerRef}
|
||||||
|
style={floatingTrigger.style}
|
||||||
|
onBlur={floatingTrigger.handleBlur}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (floatingTrigger.handleClick()) {
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen()
|
||||||
|
}}
|
||||||
|
onFocus={floatingTrigger.handleFocus}
|
||||||
|
onPointerCancel={floatingTrigger.handlePointerCancel}
|
||||||
|
onPointerDown={floatingTrigger.handlePointerDown}
|
||||||
|
onPointerEnter={floatingTrigger.handlePointerEnter}
|
||||||
|
onPointerLeave={floatingTrigger.handlePointerLeave}
|
||||||
|
onPointerMove={floatingTrigger.handlePointerMove}
|
||||||
|
onPointerUp={floatingTrigger.handlePointerUp}
|
||||||
|
>
|
||||||
|
<span className="i18n-devtool__trigger-icon" aria-hidden="true">
|
||||||
|
<LanguagesIcon />
|
||||||
|
</span>
|
||||||
|
<span className="i18n-devtool__trigger-label">i18n</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
--i18n-devtool-muted: #71717a;
|
--i18n-devtool-muted: #71717a;
|
||||||
--i18n-devtool-panel: #fff;
|
--i18n-devtool-panel: #fff;
|
||||||
--i18n-devtool-primary: #2563eb;
|
--i18n-devtool-primary: #2563eb;
|
||||||
|
--i18n-devtool-control-hover: rgb(15 23 42 / 6%);
|
||||||
|
--i18n-devtool-control-pressed: rgb(15 23 42 / 11%);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 2147483647;
|
z-index: 2147483647;
|
||||||
color: var(--i18n-devtool-foreground);
|
color: var(--i18n-devtool-foreground);
|
||||||
@@ -26,6 +28,8 @@
|
|||||||
--i18n-devtool-muted: #a1a1aa;
|
--i18n-devtool-muted: #a1a1aa;
|
||||||
--i18n-devtool-panel: #181a20;
|
--i18n-devtool-panel: #181a20;
|
||||||
--i18n-devtool-primary: #60a5fa;
|
--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;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,10 +50,13 @@
|
|||||||
right: 1rem;
|
right: 1rem;
|
||||||
bottom: 1rem;
|
bottom: 1rem;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
height: 2.75rem;
|
width: max-content;
|
||||||
|
height: 3rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.5rem;
|
||||||
padding: 0 0.85rem;
|
justify-content: center;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 0 0.5rem;
|
||||||
border: 1px solid var(--i18n-devtool-border);
|
border: 1px solid var(--i18n-devtool-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--i18n-devtool-panel);
|
background: var(--i18n-devtool-panel);
|
||||||
@@ -57,28 +64,169 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 650;
|
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;
|
display: grid;
|
||||||
width: 1.5rem;
|
width: 2rem;
|
||||||
height: 1.5rem;
|
height: 2rem;
|
||||||
|
flex: 0 0 2rem;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 0.4rem;
|
border-radius: 50%;
|
||||||
background: var(--i18n-devtool-primary);
|
background: var(--i18n-devtool-primary);
|
||||||
color: white;
|
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 {
|
.i18n-devtool__panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 1rem;
|
top: 1rem;
|
||||||
right: 1rem;
|
|
||||||
bottom: 1rem;
|
bottom: 1rem;
|
||||||
display: grid;
|
display: flex;
|
||||||
width: min(54rem, calc(100vw - 2rem));
|
width: min(80rem, calc(100vw - 2rem));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
grid-template-rows: auto auto minmax(0, 1fr);
|
flex-direction: column;
|
||||||
border: 1px solid var(--i18n-devtool-border);
|
border: 1px solid var(--i18n-devtool-border);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -86,6 +234,22 @@
|
|||||||
box-shadow: 0 24px 70px rgb(0 0 0 / 28%);
|
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"] {
|
.i18n-devtool[data-mode="standalone"] {
|
||||||
inset: 0;
|
inset: 0;
|
||||||
}
|
}
|
||||||
@@ -96,9 +260,21 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
box-shadow: none;
|
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 {
|
.i18n-devtool__header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -129,58 +305,347 @@
|
|||||||
.i18n-devtool__controls {
|
.i18n-devtool__controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: end;
|
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;
|
display: grid;
|
||||||
gap: 0.2rem;
|
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);
|
color: var(--i18n-devtool-muted);
|
||||||
font-size: 0.625rem;
|
font-size: 0.625rem;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.i18n-devtool__controls select,
|
.i18n-devtool__source-locale {
|
||||||
.i18n-devtool__controls button {
|
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;
|
height: 2.25rem;
|
||||||
padding: 0 0.7rem;
|
padding: 0 0.7rem;
|
||||||
border: 1px solid var(--i18n-devtool-border);
|
border: 1px solid var(--i18n-devtool-border);
|
||||||
border-radius: 0.55rem;
|
border-radius: 0.55rem;
|
||||||
|
outline: none;
|
||||||
background: var(--i18n-devtool-panel);
|
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;
|
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;
|
cursor: default;
|
||||||
opacity: 0.45;
|
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;
|
width: 2.25rem;
|
||||||
|
place-items: center;
|
||||||
padding: 0;
|
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 {
|
.i18n-devtool__output {
|
||||||
max-height: 10rem;
|
|
||||||
overflow: auto;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.65rem 1rem;
|
|
||||||
border-bottom: 1px solid var(--i18n-devtool-border);
|
border-bottom: 1px solid var(--i18n-devtool-border);
|
||||||
background: color-mix(
|
background: color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
var(--i18n-devtool-primary) 10%,
|
var(--i18n-devtool-primary) 10%,
|
||||||
var(--i18n-devtool-panel)
|
var(--i18n-devtool-panel)
|
||||||
);
|
);
|
||||||
font-size: 0.72rem;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.i18n-devtool__output[data-error] {
|
.i18n-devtool__output[data-error] {
|
||||||
@@ -188,15 +653,100 @@
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.i18n-devtool__main {
|
.i18n-devtool__output-header {
|
||||||
min-height: 0;
|
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;
|
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-devtool .i18n-message-panel {
|
||||||
--i18n-border: var(--i18n-devtool-border);
|
--i18n-border: var(--i18n-devtool-border);
|
||||||
--i18n-muted: var(--i18n-devtool-muted);
|
--i18n-muted: var(--i18n-devtool-muted);
|
||||||
--i18n-surface: var(--i18n-devtool-panel);
|
--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 {
|
.i18n-devtool .i18n-message-panel__toolbar {
|
||||||
@@ -209,15 +759,116 @@
|
|||||||
|
|
||||||
.i18n-devtool .i18n-message-panel input[type="search"],
|
.i18n-devtool .i18n-message-panel input[type="search"],
|
||||||
.i18n-devtool .i18n-message-panel textarea,
|
.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);
|
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) {
|
@media (max-width: 42rem) {
|
||||||
.i18n-devtool__panel {
|
.i18n-devtool__panel {
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.i18n-devtool__header {
|
.i18n-devtool__header {
|
||||||
@@ -229,8 +880,13 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.i18n-devtool__controls label {
|
.i18n-devtool__locale-field,
|
||||||
|
.i18n-devtool__source-locale-field {
|
||||||
min-width: 10rem;
|
min-width: 10rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.i18n-devtool__action-field {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,159 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { act, render, screen, waitFor } from "@testing-library/react"
|
import {
|
||||||
import { afterEach, describe, expect, it } from "vitest"
|
act,
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react"
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
import { I18nProvider } from "../runtime"
|
import { I18nProvider } from "../runtime"
|
||||||
import { I18nDevtool } from "./i18n-devtool"
|
import { I18nDevtool, type I18nDevtoolProps } from "./i18n-devtool"
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
document.documentElement.classList.remove("ui:dark")
|
document.documentElement.classList.remove("ui:dark")
|
||||||
|
window.localStorage.clear()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function renderDevtool(props?: I18nDevtoolProps) {
|
||||||
|
return render(
|
||||||
|
<I18nProvider catalogs={{ en: {} }} locale="en" locales={["en"]}>
|
||||||
|
<I18nDevtool dark="ui:dark" {...props} />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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", () => {
|
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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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(
|
||||||
|
<I18nProvider
|
||||||
|
catalogs={{ en: {}, "zh-Hans": {} }}
|
||||||
|
locale="en"
|
||||||
|
locales={[
|
||||||
|
{ label: "English", locale: "en" },
|
||||||
|
{ label: "简体中文", locale: "zh-Hans" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<I18nDevtool dark="ui:dark" defaultOpen />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
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 () => {
|
it("tracks the configured document dark-mode class", async () => {
|
||||||
render(
|
render(
|
||||||
<I18nProvider
|
<I18nProvider
|
||||||
@@ -39,4 +181,445 @@ describe("I18nDevtool", () => {
|
|||||||
expect(devtool?.hasAttribute("data-dark")).toBe(true)
|
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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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"
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import { Maximize2Icon, Minimize2Icon, XIcon } from "lucide-react"
|
||||||
import { createPortal } from "react-dom"
|
import { createPortal } from "react-dom"
|
||||||
|
|
||||||
import { useLocale, useLocales } from "../runtime"
|
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 { 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 { MessagePanel } from "./message-panel"
|
||||||
import { MessageRepositoryProvider } from "./message-repository"
|
import { MessageRepositoryProvider } from "./message-repository"
|
||||||
import "./i18n-devtool.css"
|
import "./i18n-devtool.css"
|
||||||
@@ -13,7 +32,20 @@ export interface I18nDevtoolProps {
|
|||||||
className?: string
|
className?: string
|
||||||
dark?: string
|
dark?: string
|
||||||
defaultOpen?: boolean
|
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"
|
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) {
|
function matchesDarkSelector(element: Element, dark: string) {
|
||||||
@@ -73,38 +105,98 @@ export function I18nDevtool({
|
|||||||
className,
|
className,
|
||||||
dark,
|
dark,
|
||||||
defaultOpen = false,
|
defaultOpen = false,
|
||||||
|
defaultPanelPlacement = "right",
|
||||||
|
defaultTheme = "auto",
|
||||||
|
locale: devtoolLocale,
|
||||||
mode = "floating",
|
mode = "floating",
|
||||||
|
sourceLocale,
|
||||||
}: I18nDevtoolProps) {
|
}: I18nDevtoolProps) {
|
||||||
const activeLocale = useLocale()
|
const activeLocale = useLocale()
|
||||||
const locales = useLocales()
|
const locales = useLocales()
|
||||||
|
const defaultInterfaceLocale = resolveI18nDevtoolLocale(devtoolLocale)
|
||||||
|
const [projectSourceLocale, setProjectSourceLocale] =
|
||||||
|
React.useState(sourceLocale)
|
||||||
const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(
|
const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
const panelRef = React.useRef<HTMLElement>(null)
|
||||||
const [isOpen, setOpen] = React.useState(defaultOpen || mode === "standalone")
|
const [isOpen, setOpen] = React.useState(defaultOpen || mode === "standalone")
|
||||||
const [locale, setLocale] = React.useState(activeLocale)
|
const [messageLocale, setMessageLocale] = React.useState(
|
||||||
const [reloadKey, setReloadKey] = React.useState(0)
|
() => activeLocale ?? locales[0]?.locale ?? ""
|
||||||
|
)
|
||||||
|
const [messageRevision, setMessageRevision] = React.useState(0)
|
||||||
const [actionState, setActionState] = React.useState<DevtoolAction | "idle">(
|
const [actionState, setActionState] = React.useState<DevtoolAction | "idle">(
|
||||||
"idle"
|
"idle"
|
||||||
)
|
)
|
||||||
const [actionOutput, setActionOutput] = React.useState("")
|
const [actionOutput, setActionOutput] = React.useState("")
|
||||||
|
const [isActionOutputExpanded, setActionOutputExpanded] = React.useState(true)
|
||||||
|
const [isFullscreen, setFullscreen] = React.useState(false)
|
||||||
const [error, setError] = React.useState("")
|
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(
|
const repository = React.useMemo(
|
||||||
() => createHttpMessageRepository(apiBaseUrl),
|
() => createHttpMessageRepository(apiBaseUrl),
|
||||||
[apiBaseUrl]
|
[apiBaseUrl]
|
||||||
)
|
)
|
||||||
|
const sourceLocaleLabel = projectSourceLocale
|
||||||
|
? (locales.find((definition) => definition.locale === projectSourceLocale)
|
||||||
|
?.label ?? projectSourceLocale)
|
||||||
|
: undefined
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setPortalTarget(document.body)
|
setPortalTarget(document.body)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (locales.some((definition) => definition.locale === locale)) {
|
if (sourceLocale) {
|
||||||
|
setProjectSourceLocale(sourceLocale)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocale(activeLocale)
|
let cancelled = false
|
||||||
}, [activeLocale, locale, locales])
|
|
||||||
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!isOpen || mode === "standalone") {
|
if (!isOpen || mode === "standalone") {
|
||||||
@@ -113,25 +205,67 @@ export function I18nDevtool({
|
|||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
setOpen(false)
|
if (isFullscreen) {
|
||||||
|
setFullscreen(false)
|
||||||
|
} else {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
return () => window.removeEventListener("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])
|
}, [isOpen, mode])
|
||||||
|
|
||||||
const runAction = async (action: DevtoolAction) => {
|
const runAction = async (action: DevtoolAction) => {
|
||||||
setActionState(action)
|
setActionState(action)
|
||||||
setActionOutput("")
|
setActionOutput(
|
||||||
|
action === "extract"
|
||||||
|
? messages.actions.extracting
|
||||||
|
: messages.actions.compiling
|
||||||
|
)
|
||||||
|
setActionOutputExpanded(true)
|
||||||
setError("")
|
setError("")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runDevtoolAction(action, apiBaseUrl)
|
const result = await runDevtoolAction(action, apiBaseUrl)
|
||||||
setActionOutput(result.output.trim() || `${action} completed.`)
|
setActionOutput(
|
||||||
setReloadKey((current) => current + 1)
|
result.output.trim() ||
|
||||||
|
(action === "extract"
|
||||||
|
? messages.actions.extractCompleted
|
||||||
|
: messages.actions.compileCompleted)
|
||||||
|
)
|
||||||
|
setMessageRevision((current) => current + 1)
|
||||||
} catch (reason) {
|
} 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 {
|
} finally {
|
||||||
setActionState("idle")
|
setActionState("idle")
|
||||||
}
|
}
|
||||||
@@ -142,99 +276,165 @@ export function I18nDevtool({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rootClassName = ["i18n-devtool", className].filter(Boolean).join(" ")
|
const rootClassName = ["i18n-devtool", className].filter(Boolean).join(" ")
|
||||||
|
const closeActionOutput = () => {
|
||||||
|
setActionOutput("")
|
||||||
|
setError("")
|
||||||
|
}
|
||||||
|
const closePanel = () => {
|
||||||
|
setFullscreen(false)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<DevtoolLocalizationProvider locale={resolvedDevtoolLocale}>
|
||||||
className={rootClassName}
|
<div
|
||||||
data-dark={isDark || undefined}
|
className={rootClassName}
|
||||||
data-mode={mode}
|
data-dark={isDark || undefined}
|
||||||
data-slot="i18n-devtool"
|
data-mode={mode}
|
||||||
>
|
data-slot="i18n-devtool"
|
||||||
{mode === "floating" && !isOpen && (
|
data-theme={theme}
|
||||||
<button
|
lang={resolvedDevtoolLocale}
|
||||||
type="button"
|
>
|
||||||
aria-controls="i18n-devtool-panel"
|
{mode === "floating" && (
|
||||||
aria-expanded="false"
|
<I18nDevtoolTrigger open={isOpen} onOpen={() => setOpen(true)} />
|
||||||
className="i18n-devtool__trigger"
|
)}
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">文</span>
|
|
||||||
<span>i18n</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isOpen && (
|
<React.Activity
|
||||||
<section
|
mode={isOpen ? "visible" : "hidden"}
|
||||||
id="i18n-devtool-panel"
|
name="I18n Devtool panel"
|
||||||
aria-label="I18n Devtool"
|
|
||||||
className="i18n-devtool__panel"
|
|
||||||
role="dialog"
|
|
||||||
>
|
>
|
||||||
<header className="i18n-devtool__header">
|
<section
|
||||||
<div>
|
id="i18n-devtool-panel"
|
||||||
<p className="i18n-devtool__eyebrow">Lingui</p>
|
aria-label="I18n Devtool"
|
||||||
<h2>I18n Devtool</h2>
|
className="i18n-devtool__panel"
|
||||||
</div>
|
data-fullscreen={mode === "standalone" || isFullscreen || undefined}
|
||||||
|
data-placement={panelPlacement}
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<header className="i18n-devtool__header">
|
||||||
|
<div>
|
||||||
|
<p className="i18n-devtool__eyebrow">Lingui</p>
|
||||||
|
<h2>I18n Devtool</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="i18n-devtool__controls">
|
<div className="i18n-devtool__controls">
|
||||||
<label>
|
<DevtoolLocaleSelect
|
||||||
<span>Locale</span>
|
locale={messageLocale}
|
||||||
<select
|
locales={locales}
|
||||||
aria-label="Devtool locale"
|
sourceLocaleLabel={sourceLocaleLabel}
|
||||||
value={locale}
|
onLocaleChange={setMessageLocale}
|
||||||
onChange={(event) => setLocale(event.target.value)}
|
/>
|
||||||
>
|
<fieldset className="i18n-devtool__action-field">
|
||||||
{locales.map((definition) => (
|
<legend>{messages.actions.label}</legend>
|
||||||
<option key={definition.locale} value={definition.locale}>
|
<div className="i18n-devtool__action-controls">
|
||||||
{definition.label ?? definition.locale}
|
<button
|
||||||
</option>
|
type="button"
|
||||||
))}
|
className="i18n-devtool__action-button"
|
||||||
</select>
|
disabled={actionState !== "idle"}
|
||||||
</label>
|
onClick={() => void runAction("extract")}
|
||||||
<button
|
>
|
||||||
type="button"
|
<span>
|
||||||
disabled={actionState !== "idle"}
|
{actionState === "extract"
|
||||||
onClick={() => void runAction("extract")}
|
? messages.actions.extracting
|
||||||
>
|
: messages.actions.extract}
|
||||||
{actionState === "extract" ? "Extracting…" : "Extract"}
|
</span>
|
||||||
</button>
|
<span
|
||||||
<button
|
aria-hidden="true"
|
||||||
type="button"
|
className="i18n-devtool__action-label-measure"
|
||||||
disabled={actionState !== "idle"}
|
>
|
||||||
onClick={() => void runAction("compile")}
|
{messages.actions.extracting}
|
||||||
>
|
</span>
|
||||||
{actionState === "compile" ? "Compiling…" : "Compile"}
|
</button>
|
||||||
</button>
|
<button
|
||||||
{mode === "floating" && (
|
type="button"
|
||||||
<button
|
className="i18n-devtool__action-button"
|
||||||
type="button"
|
disabled={actionState !== "idle"}
|
||||||
aria-label="Close I18n Devtool"
|
onClick={() => void runAction("compile")}
|
||||||
className="i18n-devtool__close"
|
>
|
||||||
onClick={() => setOpen(false)}
|
<span>
|
||||||
>
|
{actionState === "compile"
|
||||||
×
|
? messages.actions.compiling
|
||||||
</button>
|
: messages.actions.compile}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="i18n-devtool__action-label-measure"
|
||||||
|
>
|
||||||
|
{messages.actions.compiling}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<DevtoolSettings
|
||||||
|
interfaceLocale={resolvedDevtoolLocale}
|
||||||
|
panelPlacement={panelPlacement}
|
||||||
|
showPanelPlacement={mode === "floating"}
|
||||||
|
theme={theme}
|
||||||
|
onInterfaceLocaleChange={setInterfaceLocale}
|
||||||
|
onPanelPlacementChange={setPanelPlacement}
|
||||||
|
onThemeChange={setTheme}
|
||||||
|
/>
|
||||||
|
{mode === "floating" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={
|
||||||
|
isFullscreen
|
||||||
|
? messages.actions.exitFullscreen
|
||||||
|
: messages.actions.enterFullscreen
|
||||||
|
}
|
||||||
|
aria-pressed={isFullscreen}
|
||||||
|
className="i18n-devtool__fullscreen"
|
||||||
|
onClick={() => setFullscreen((current) => !current)}
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<Minimize2Icon aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Maximize2Icon aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{mode === "floating" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={messages.trigger.close}
|
||||||
|
className="i18n-devtool__close"
|
||||||
|
onClick={closePanel}
|
||||||
|
>
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{(error || actionOutput) && (
|
||||||
|
<DevtoolActionOutput
|
||||||
|
error={Boolean(error)}
|
||||||
|
expanded={isActionOutputExpanded}
|
||||||
|
output={error || actionOutput}
|
||||||
|
onClose={closeActionOutput}
|
||||||
|
onExpandedChange={setActionOutputExpanded}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main className="i18n-devtool__main">
|
||||||
|
{messageLocale ? (
|
||||||
|
<MessageRepositoryProvider repository={repository}>
|
||||||
|
<MessagePanel
|
||||||
|
locale={messageLocale}
|
||||||
|
refreshToken={messageRevision}
|
||||||
|
/>
|
||||||
|
</MessageRepositoryProvider>
|
||||||
|
) : (
|
||||||
|
<div className="i18n-devtool__empty">
|
||||||
|
{messages.locale.noTargetLocales}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</main>
|
||||||
</header>
|
</section>
|
||||||
|
</React.Activity>
|
||||||
{(error || actionOutput) && (
|
</div>
|
||||||
<pre
|
</DevtoolLocalizationProvider>,
|
||||||
className="i18n-devtool__output"
|
|
||||||
data-error={error ? "" : undefined}
|
|
||||||
>
|
|
||||||
{error || actionOutput}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<main className="i18n-devtool__main">
|
|
||||||
<MessageRepositoryProvider repository={repository}>
|
|
||||||
<MessagePanel key={`${locale}:${reloadKey}`} locale={locale} />
|
|
||||||
</MessageRepositoryProvider>
|
|
||||||
</main>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>,
|
|
||||||
portalTarget
|
portalTarget
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
export { createHttpMessageRepository } from "./http-message-repository"
|
export { createHttpMessageRepository } from "./http-message-repository"
|
||||||
export { I18nDevtool, type I18nDevtoolProps } from "./i18n-devtool"
|
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 { MessagePanel, type MessagePanelProps } from "./message-panel"
|
||||||
export {
|
export {
|
||||||
MessageRepositoryProvider,
|
MessageRepositoryProvider,
|
||||||
@@ -8,6 +21,7 @@ export {
|
|||||||
} from "./message-repository"
|
} from "./message-repository"
|
||||||
export type {
|
export type {
|
||||||
DevtoolMessage,
|
DevtoolMessage,
|
||||||
|
DevtoolMessagePackage,
|
||||||
MessageOrigin,
|
MessageOrigin,
|
||||||
MessageRepository,
|
MessageRepository,
|
||||||
UpdateMessageInput,
|
UpdateMessageInput,
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(null)
|
||||||
|
const triggerRef = React.useRef<HTMLButtonElement>(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<HTMLButtonElement>('[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<HTMLDivElement>) => {
|
||||||
|
if (!["ArrowDown", "ArrowUp", "End", "Home"].includes(event.key)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const items = Array.from(
|
||||||
|
event.currentTarget.querySelectorAll<HTMLButtonElement>(
|
||||||
|
'[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 (
|
||||||
|
<div className="i18n-message-copy" ref={rootRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label={
|
||||||
|
copyState === "copied"
|
||||||
|
? messages.messagePanel.copied
|
||||||
|
: copyState === "error"
|
||||||
|
? messages.messagePanel.copyFailed
|
||||||
|
: messages.messagePanel.chooseCopyContent
|
||||||
|
}
|
||||||
|
className="i18n-message-copy__trigger"
|
||||||
|
data-state={copyState}
|
||||||
|
ref={triggerRef}
|
||||||
|
title={messages.messagePanel.chooseCopyContent}
|
||||||
|
onClick={() => {
|
||||||
|
if (isOpen) {
|
||||||
|
closeMenu()
|
||||||
|
} else {
|
||||||
|
openMenu()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copyState === "copied" ? (
|
||||||
|
<CheckIcon aria-hidden="true" />
|
||||||
|
) : copyState === "error" ? (
|
||||||
|
<TriangleAlertIcon aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<CopyIcon aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<ChevronDownIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="i18n-message-copy__chevron"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="i18n-message-copy__menu"
|
||||||
|
role="menu"
|
||||||
|
onKeyDown={handleMenuKeyDown}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => void copy(message.id)}
|
||||||
|
>
|
||||||
|
{messages.messagePanel.copyMessageId}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => void copy(message.source || message.id)}
|
||||||
|
>
|
||||||
|
{messages.messagePanel.copySource}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={translation.length === 0}
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => void copy(translation)}
|
||||||
|
>
|
||||||
|
{messages.messagePanel.copyTranslation}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{message.origins.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="i18n-message-copy__separator" role="separator" />
|
||||||
|
<span className="i18n-message-copy__label">
|
||||||
|
{messages.messagePanel.sourceLocations}
|
||||||
|
</span>
|
||||||
|
{message.origins.map((origin, index) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={`${getOriginLocation(origin)}:${index}`}
|
||||||
|
role="menuitem"
|
||||||
|
title={getOriginLocation(origin)}
|
||||||
|
onClick={() => void copy(getOriginLocation(origin))}
|
||||||
|
>
|
||||||
|
<span>{messages.messagePanel.copySourceLocation}</span>
|
||||||
|
<code>{formatMessageOrigin(origin)}</code>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="i18n-message-copy__separator" role="separator" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => void copy(serializeMessageDescriptor(message))}
|
||||||
|
>
|
||||||
|
{messages.messagePanel.copyDescriptor}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{copyState !== "idle" && !isOpen && (
|
||||||
|
<span
|
||||||
|
aria-atomic="true"
|
||||||
|
aria-live="polite"
|
||||||
|
className="i18n-message-copy__feedback"
|
||||||
|
data-state={copyState}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{copyState === "copied" ? (
|
||||||
|
<CheckIcon aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<TriangleAlertIcon aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{copyState === "copied"
|
||||||
|
? messages.messagePanel.copied
|
||||||
|
: messages.messagePanel.copyFailed}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string, MessageNamespaceNode>
|
||||||
|
descendantCount: number
|
||||||
|
messages: DevtoolMessage[]
|
||||||
|
path: string
|
||||||
|
segment: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessagePackageNode extends MessagePackageGroup {
|
||||||
|
messagesAtRoot: readonly DevtoolMessage[]
|
||||||
|
namespaces: Map<string, MessageNamespaceNode>
|
||||||
|
selectionKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageNavigationSelection {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
messages: readonly DevtoolMessage[]
|
||||||
|
packageName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageNavigationModel {
|
||||||
|
packages: readonly MessagePackageNode[]
|
||||||
|
selections: ReadonlyMap<string, MessageNavigationSelection>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, MessageNavigationSelection>()
|
||||||
|
const packages = groups.map<MessagePackageNode>((group) => {
|
||||||
|
const messagesAtRoot: DevtoolMessage[] = []
|
||||||
|
const namespaces = new Map<string, MessageNamespaceNode>()
|
||||||
|
|
||||||
|
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<string, MessageNamespaceNode>
|
||||||
|
) => {
|
||||||
|
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<string, MessageNamespaceNode>
|
||||||
|
onSelect: (key: string) => void
|
||||||
|
packageName: string
|
||||||
|
selectedKey?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ul className="i18n-message-tree__branch">
|
||||||
|
{Array.from(nodes.values())
|
||||||
|
.sort((left, right) => left.segment.localeCompare(right.segment))
|
||||||
|
.map((node) => (
|
||||||
|
<NamespaceNode
|
||||||
|
key={node.path}
|
||||||
|
node={node}
|
||||||
|
packageName={packageName}
|
||||||
|
selectedKey={selectedKey}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
className="i18n-message-tree__namespace-row"
|
||||||
|
data-selected={isSelected || undefined}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||||
|
aria-label={
|
||||||
|
hasChildren
|
||||||
|
? isExpanded
|
||||||
|
? messages.messagePanel.collapseNamespace(node.path)
|
||||||
|
: messages.messagePanel.expandNamespace(node.path)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className="i18n-message-tree__disclosure"
|
||||||
|
disabled={!hasChildren}
|
||||||
|
onClick={() => setExpanded((current) => !current)}
|
||||||
|
>
|
||||||
|
{hasChildren && (
|
||||||
|
<ChevronDownIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="i18n-message-tree__chevron"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-current={isSelected ? "true" : undefined}
|
||||||
|
className="i18n-message-tree__namespace"
|
||||||
|
data-selectable={hasMessages || undefined}
|
||||||
|
title={node.path}
|
||||||
|
onClick={() => {
|
||||||
|
if (hasMessages) {
|
||||||
|
onSelect(selectionKey)
|
||||||
|
} else if (hasChildren) {
|
||||||
|
setExpanded((current) => !current)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderIcon aria-hidden="true" />
|
||||||
|
<span>{node.segment}</span>
|
||||||
|
<small>{node.descendantCount}</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{hasChildren && (
|
||||||
|
<div hidden={!isExpanded}>
|
||||||
|
<NamespaceBranch
|
||||||
|
nodes={node.children}
|
||||||
|
packageName={packageName}
|
||||||
|
selectedKey={selectedKey}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section
|
||||||
|
aria-label={node.packageName}
|
||||||
|
className="i18n-message-package"
|
||||||
|
data-collapsed={!isExpanded || undefined}
|
||||||
|
data-kind={node.kind}
|
||||||
|
data-selected={isSelected || undefined}
|
||||||
|
>
|
||||||
|
<header className="i18n-message-package__header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-current={isSelected ? "true" : undefined}
|
||||||
|
className="i18n-message-package__identity"
|
||||||
|
onClick={() => onSelect(node.selectionKey)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="i18n-message-package__icon">
|
||||||
|
<PackageIcon />
|
||||||
|
</span>
|
||||||
|
<code>{node.packageName}</code>
|
||||||
|
</button>
|
||||||
|
<span className="i18n-message-package__controls">
|
||||||
|
<span className="i18n-message-package__counts">
|
||||||
|
<span>
|
||||||
|
{messages.messagePanel.messageCount(node.messages.length)}
|
||||||
|
</span>
|
||||||
|
{node.missingCount > 0 && (
|
||||||
|
<span data-missing>
|
||||||
|
{messages.messagePanel.missingCount(node.missingCount)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-controls={hasContent ? contentId : undefined}
|
||||||
|
aria-expanded={hasContent ? isExpanded : undefined}
|
||||||
|
aria-label={
|
||||||
|
hasContent
|
||||||
|
? isExpanded
|
||||||
|
? messages.messagePanel.collapsePackage(node.packageName)
|
||||||
|
: messages.messagePanel.expandPackage(node.packageName)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className="i18n-message-package__disclosure"
|
||||||
|
disabled={!hasContent}
|
||||||
|
onClick={() => setExpanded((current) => !current)}
|
||||||
|
>
|
||||||
|
{hasContent && (
|
||||||
|
<ChevronDownIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="i18n-message-package__chevron"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
{hasContent && (
|
||||||
|
<div
|
||||||
|
id={contentId}
|
||||||
|
className="i18n-message-package__messages"
|
||||||
|
hidden={!isExpanded}
|
||||||
|
>
|
||||||
|
{node.messages.length === 0 ? (
|
||||||
|
<div className="i18n-message-package__empty">
|
||||||
|
{messages.messagePanel.emptyPackage}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<NamespaceBranch
|
||||||
|
nodes={node.namespaces}
|
||||||
|
packageName={node.packageName}
|
||||||
|
selectedKey={selectedKey}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageNavigation({
|
||||||
|
model,
|
||||||
|
onSelect,
|
||||||
|
selectedKey,
|
||||||
|
}: {
|
||||||
|
model: MessageNavigationModel
|
||||||
|
onSelect: (key: string) => void
|
||||||
|
selectedKey?: string
|
||||||
|
}) {
|
||||||
|
const { messages } = useDevtoolLocalization()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label={messages.messagePanel.messageNavigation}
|
||||||
|
className="i18n-message-navigation"
|
||||||
|
>
|
||||||
|
{model.packages.map((node) => (
|
||||||
|
<PackageTree
|
||||||
|
key={node.packageName}
|
||||||
|
node={node}
|
||||||
|
selectedKey={selectedKey}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
--i18n-muted: color-mix(in srgb, currentColor 62%, transparent);
|
--i18n-muted: color-mix(in srgb, currentColor 62%, transparent);
|
||||||
--i18n-surface: color-mix(in srgb, Canvas 96%, currentColor 4%);
|
--i18n-surface: color-mix(in srgb, Canvas 96%, currentColor 4%);
|
||||||
color: CanvasText;
|
color: CanvasText;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
font-family:
|
font-family:
|
||||||
Inter,
|
Inter,
|
||||||
ui-sans-serif,
|
ui-sans-serif,
|
||||||
@@ -33,8 +36,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.i18n-message-panel__search {
|
.i18n-message-panel__search {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
min-width: 12rem;
|
min-width: 12rem;
|
||||||
flex: 1;
|
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,
|
.i18n-message-panel input,
|
||||||
@@ -55,16 +71,24 @@
|
|||||||
|
|
||||||
.i18n-message-panel input[type="search"] {
|
.i18n-message-panel input[type="search"] {
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
padding: 0 0.75rem;
|
padding: 0 3.25rem 0 2.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.i18n-message-panel textarea {
|
.i18n-message-panel textarea {
|
||||||
min-height: 5.5rem;
|
min-height: 3.25rem;
|
||||||
|
max-height: 14rem;
|
||||||
|
field-sizing: content;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
padding: 0.75rem;
|
padding: 0.65rem 0.75rem;
|
||||||
line-height: 1.5;
|
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 input[type="search"]:focus,
|
||||||
.i18n-message-panel textarea:focus {
|
.i18n-message-panel textarea:focus {
|
||||||
border-color: AccentColor;
|
border-color: AccentColor;
|
||||||
@@ -72,32 +96,464 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.i18n-message-panel__toggle {
|
.i18n-message-panel__toggle {
|
||||||
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 0.875rem;
|
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 {
|
.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);
|
color: var(--i18n-muted);
|
||||||
font-variant-numeric: tabular-nums;
|
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;
|
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;
|
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 {
|
.i18n-message {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.875rem;
|
gap: 0.65rem;
|
||||||
padding: 1rem;
|
overflow: visible;
|
||||||
|
padding: 0.875rem;
|
||||||
border: 1px solid var(--i18n-border);
|
border: 1px solid var(--i18n-border);
|
||||||
border-radius: 0.875rem;
|
border-radius: 0.75rem;
|
||||||
background: var(--i18n-surface);
|
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] {
|
.i18n-message[data-missing] {
|
||||||
@@ -108,6 +564,210 @@
|
|||||||
opacity: 0.65;
|
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__header,
|
||||||
.i18n-message__actions,
|
.i18n-message__actions,
|
||||||
.i18n-message__meta {
|
.i18n-message__meta {
|
||||||
@@ -118,20 +778,31 @@
|
|||||||
|
|
||||||
.i18n-message__header {
|
.i18n-message__header {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.i18n-message__id {
|
.i18n-message__id {
|
||||||
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
color: color-mix(in srgb, CanvasText 88%, transparent);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.i18n-message__catalog {
|
.i18n-message__content {
|
||||||
color: var(--i18n-muted);
|
display: grid;
|
||||||
font-size: 0.75rem;
|
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 {
|
.i18n-message__source p {
|
||||||
margin: 0.25rem 0 0;
|
margin: 0.3rem 0 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +812,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.i18n-message__label {
|
.i18n-message__label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
color: var(--i18n-muted);
|
color: var(--i18n-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -148,24 +822,65 @@
|
|||||||
text-transform: uppercase;
|
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 {
|
.i18n-message__meta {
|
||||||
|
min-width: 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
color: var(--i18n-muted);
|
color: var(--i18n-muted);
|
||||||
font-size: 0.75rem;
|
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 {
|
.i18n-message__actions {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
min-height: 2.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.i18n-message__status {
|
.i18n-message__status {
|
||||||
margin-inline-end: auto;
|
margin-inline-end: auto;
|
||||||
color: var(--i18n-muted);
|
color: var(--i18n-muted);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
font-size: 0.75rem;
|
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 {
|
.i18n-message-panel button {
|
||||||
|
display: inline-flex;
|
||||||
min-height: 2.25rem;
|
min-height: 2.25rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
padding: 0 0.8rem;
|
padding: 0 0.8rem;
|
||||||
border: 1px solid var(--i18n-border);
|
border: 1px solid var(--i18n-border);
|
||||||
border-radius: 0.55rem;
|
border-radius: 0.55rem;
|
||||||
@@ -173,10 +888,29 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.i18n-message-panel button svg {
|
||||||
|
width: 0.95rem;
|
||||||
|
height: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
.i18n-message-panel button:hover:not(:disabled) {
|
.i18n-message-panel button:hover:not(:disabled) {
|
||||||
background: color-mix(in srgb, Canvas 90%, currentColor 10%);
|
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 {
|
.i18n-message-panel button:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
@@ -215,4 +949,33 @@
|
|||||||
.i18n-message-panel__search {
|
.i18n-message-panel__search {
|
||||||
flex-basis: 100%;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
import {
|
||||||
import { describe, expect, it, vi } from "vitest"
|
act,
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react"
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
import { MessagePanel } from "./message-panel"
|
import { MessagePanel } from "./message-panel"
|
||||||
import type { DevtoolMessage, MessageRepository } from "./types"
|
import type { DevtoolMessage, MessageRepository } from "./types"
|
||||||
@@ -10,14 +17,23 @@ import type { DevtoolMessage, MessageRepository } from "./types"
|
|||||||
const message: DevtoolMessage = {
|
const message: DevtoolMessage = {
|
||||||
catalog: "messages",
|
catalog: "messages",
|
||||||
comments: ["Navigation title"],
|
comments: ["Navigation title"],
|
||||||
|
editable: true,
|
||||||
id: "navigation.home",
|
id: "navigation.home",
|
||||||
missing: true,
|
missing: true,
|
||||||
obsolete: false,
|
obsolete: false,
|
||||||
origins: [{ file: "src/navigation.tsx", line: 12 }],
|
origins: [{ file: "src/navigation.tsx", line: 12 }],
|
||||||
|
packageName: "@workspace/ui",
|
||||||
source: "Home",
|
source: "Home",
|
||||||
|
sourceLocale: "en",
|
||||||
translation: "",
|
translation: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
Reflect.deleteProperty(window.navigator, "clipboard")
|
||||||
|
})
|
||||||
|
|
||||||
describe("MessagePanel", () => {
|
describe("MessagePanel", () => {
|
||||||
it("loads, filters, and saves messages through the repository", async () => {
|
it("loads, filters, and saves messages through the repository", async () => {
|
||||||
const updateMessage = vi.fn(async (input) => ({
|
const updateMessage = vi.fn(async (input) => ({
|
||||||
@@ -33,10 +49,13 @@ describe("MessagePanel", () => {
|
|||||||
render(<MessagePanel locale="zh-Hans" repository={repository} />)
|
render(<MessagePanel locale="zh-Hans" repository={repository} />)
|
||||||
|
|
||||||
expect(await screen.findByText("navigation.home")).toBeTruthy()
|
expect(await screen.findByText("navigation.home")).toBeTruthy()
|
||||||
|
expect(screen.getByRole("region", { name: "@workspace/ui" })).toBeTruthy()
|
||||||
|
|
||||||
const textarea = screen.getByLabelText(
|
const textarea = screen.getByLabelText(
|
||||||
"navigation.home translation for zh-Hans"
|
"navigation.home translation for zh-Hans"
|
||||||
)
|
)
|
||||||
|
expect(screen.queryByRole("button", { name: "Save" })).toBeNull()
|
||||||
|
|
||||||
fireEvent.change(textarea, { target: { value: "首页" } })
|
fireEvent.change(textarea, { target: { value: "首页" } })
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save" }))
|
fireEvent.click(screen.getByRole("button", { name: "Save" }))
|
||||||
|
|
||||||
@@ -50,11 +69,323 @@ describe("MessagePanel", () => {
|
|||||||
})
|
})
|
||||||
expect(await screen.findByText("Saved")).toBeTruthy()
|
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"), {
|
fireEvent.change(screen.getByPlaceholderText("Search messages"), {
|
||||||
target: { value: "does not exist" },
|
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<readonly DevtoolMessage[]>((resolve) => {
|
||||||
|
resolveRefresh = resolve
|
||||||
|
})
|
||||||
|
),
|
||||||
|
updateMessage: vi.fn(),
|
||||||
|
}
|
||||||
|
const view = render(
|
||||||
|
<MessagePanel locale="zh-Hans" refreshToken={0} repository={repository} />
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByText("navigation.home")).toBeTruthy()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Search messages"), {
|
||||||
|
target: { value: "home" },
|
||||||
|
})
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<MessagePanel locale="zh-Hans" refreshToken={1} repository={repository} />
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
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()
|
).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("collapses a package without discarding an unsaved translation", async () => {
|
||||||
|
const repository: MessageRepository = {
|
||||||
|
getMessages: vi.fn(async () => [message]),
|
||||||
|
updateMessage: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<MessagePanel locale="zh-Hans" repository={repository} />)
|
||||||
|
|
||||||
|
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(<MessagePanel locale="zh-Hans" repository={repository} />)
|
||||||
|
|
||||||
|
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(<MessagePanel locale="zh-Hans" repository={repository} />)
|
||||||
|
|
||||||
|
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(<MessagePanel locale="zh-Hans" repository={repository} />)
|
||||||
|
|
||||||
|
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(<MessagePanel locale="zh-Hans" repository={repository} />)
|
||||||
|
|
||||||
|
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(<MessagePanel locale="en" repository={repository} />)
|
||||||
|
|
||||||
|
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(<MessagePanel locale="zh-Hans" repository={repository} />)
|
||||||
|
|
||||||
|
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(<MessagePanel locale="zh-Hans" repository={repository} />)
|
||||||
|
|
||||||
|
expect(await screen.findByText("navigation.home")).toBeTruthy()
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("checkbox", {
|
||||||
|
name: "Missing only",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText("All messages have translations.")).toBeTruthy()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,17 +1,40 @@
|
|||||||
import * as React from "react"
|
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 { useOptionalMessageRepository } from "./message-repository"
|
||||||
import type { DevtoolMessage, MessageRepository } from "./types"
|
import type {
|
||||||
|
DevtoolMessage,
|
||||||
|
DevtoolMessagePackage,
|
||||||
|
MessageRepository,
|
||||||
|
} from "./types"
|
||||||
import "./message-panel.css"
|
import "./message-panel.css"
|
||||||
|
|
||||||
type LoadState =
|
type LoadState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { error: Error; status: "error" }
|
| { error: Error; status: "error" }
|
||||||
| { messages: readonly DevtoolMessage[]; status: "ready" }
|
| {
|
||||||
|
messages: readonly DevtoolMessage[]
|
||||||
|
packages: readonly DevtoolMessagePackage[]
|
||||||
|
status: "ready"
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessagePanelProps {
|
export interface MessagePanelProps {
|
||||||
className?: string
|
className?: string
|
||||||
locale: string
|
locale: string
|
||||||
|
refreshToken?: unknown
|
||||||
repository?: MessageRepository
|
repository?: MessageRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +43,7 @@ function getMessageSearchText(message: DevtoolMessage) {
|
|||||||
message.id,
|
message.id,
|
||||||
message.source,
|
message.source,
|
||||||
message.translation,
|
message.translation,
|
||||||
|
message.packageName,
|
||||||
...message.comments,
|
...message.comments,
|
||||||
...message.origins.map((origin) => origin.file),
|
...message.origins.map((origin) => origin.file),
|
||||||
]
|
]
|
||||||
@@ -36,14 +60,23 @@ function MessageEditor({
|
|||||||
message: DevtoolMessage
|
message: DevtoolMessage
|
||||||
onSave: (translation: string) => Promise<void>
|
onSave: (translation: string) => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
|
const { messages } = useDevtoolLocalization()
|
||||||
const [translation, setTranslation] = React.useState(message.translation)
|
const [translation, setTranslation] = React.useState(message.translation)
|
||||||
const [saveState, setSaveState] = React.useState<
|
const [saveState, setSaveState] = React.useState<
|
||||||
"idle" | "saving" | "saved" | "error"
|
"idle" | "saving" | "saved" | "error"
|
||||||
>("idle")
|
>("idle")
|
||||||
|
const repositoryTranslationRef = React.useRef(message.translation)
|
||||||
const submittedTranslationRef = React.useRef<string | undefined>(undefined)
|
const submittedTranslationRef = React.useRef<string | undefined>(undefined)
|
||||||
|
const isEditable = message.editable
|
||||||
const isDirty = translation !== message.translation
|
const isDirty = translation !== message.translation
|
||||||
|
const primaryOrigin = message.origins[0]
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (repositoryTranslationRef.current === message.translation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repositoryTranslationRef.current = message.translation
|
||||||
setTranslation(message.translation)
|
setTranslation(message.translation)
|
||||||
|
|
||||||
if (submittedTranslationRef.current === message.translation) {
|
if (submittedTranslationRef.current === message.translation) {
|
||||||
@@ -54,8 +87,17 @@ function MessageEditor({
|
|||||||
setSaveState("idle")
|
setSaveState("idle")
|
||||||
}, [message.translation])
|
}, [message.translation])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (saveState !== "saved") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = window.setTimeout(() => setSaveState("idle"), 1600)
|
||||||
|
return () => window.clearTimeout(timeout)
|
||||||
|
}, [saveState])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!isDirty || saveState === "saving") {
|
if (!isEditable || !isDirty || saveState === "saving") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,69 +113,152 @@ function MessageEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
submittedTranslationRef.current = undefined
|
||||||
|
setTranslation(message.translation)
|
||||||
|
setSaveState("idle")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className="i18n-message"
|
className="i18n-message"
|
||||||
|
data-dirty={isDirty || undefined}
|
||||||
data-missing={message.missing || undefined}
|
data-missing={message.missing || undefined}
|
||||||
data-obsolete={message.obsolete || undefined}
|
data-obsolete={message.obsolete || undefined}
|
||||||
|
data-readonly={!isEditable || undefined}
|
||||||
|
data-save-state={saveState}
|
||||||
>
|
>
|
||||||
<header className="i18n-message__header">
|
<header className="i18n-message__source-toolbar">
|
||||||
<code className="i18n-message__id">{message.id}</code>
|
<div
|
||||||
<span className="i18n-message__catalog">{message.catalog}</span>
|
className="i18n-message__source-location"
|
||||||
|
title={
|
||||||
|
primaryOrigin
|
||||||
|
? `${primaryOrigin.file}${primaryOrigin.line ? `:${primaryOrigin.line}` : ""}`
|
||||||
|
: messages.messagePanel.unknownSourceLocation
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FileCode2Icon aria-hidden="true" />
|
||||||
|
<code>
|
||||||
|
{primaryOrigin
|
||||||
|
? formatMessageOrigin(primaryOrigin)
|
||||||
|
: messages.messagePanel.unknownSourceLocation}
|
||||||
|
</code>
|
||||||
|
{message.origins.length > 1 && (
|
||||||
|
<span
|
||||||
|
aria-label={messages.messagePanel.additionalSourceLocations(
|
||||||
|
message.origins.length - 1
|
||||||
|
)}
|
||||||
|
className="i18n-message__source-count"
|
||||||
|
>
|
||||||
|
+{message.origins.length - 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<MessageCopyMenu message={message} translation={translation} />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="i18n-message__source">
|
<div className="i18n-message__header">
|
||||||
<span className="i18n-message__label">Source</span>
|
<code className="i18n-message__id">{message.id}</code>
|
||||||
<p>{message.source || message.id}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="i18n-message__field">
|
<div className="i18n-message__content">
|
||||||
<span className="i18n-message__label">{locale}</span>
|
<div className="i18n-message__source">
|
||||||
<textarea
|
<span className="i18n-message__label">
|
||||||
aria-label={`${message.id} translation for ${locale}`}
|
{messages.messagePanel.source}
|
||||||
value={translation}
|
<code>{message.sourceLocale}</code>
|
||||||
onChange={(event) => {
|
</span>
|
||||||
submittedTranslationRef.current = undefined
|
<p dir="auto" lang={message.sourceLocale}>
|
||||||
setTranslation(event.target.value)
|
{message.source || message.id}
|
||||||
setSaveState("idle")
|
</p>
|
||||||
}}
|
</div>
|
||||||
onKeyDown={(event) => {
|
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
|
||||||
event.preventDefault()
|
|
||||||
void handleSave()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{(message.comments.length > 0 || message.origins.length > 0) && (
|
<label className="i18n-message__field">
|
||||||
|
<span className="i18n-message__label">
|
||||||
|
{messages.messagePanel.translation}
|
||||||
|
<code>{locale}</code>
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
aria-label={messages.messagePanel.translationFor(
|
||||||
|
message.id,
|
||||||
|
locale
|
||||||
|
)}
|
||||||
|
dir="auto"
|
||||||
|
lang={locale}
|
||||||
|
rows={1}
|
||||||
|
readOnly={!isEditable}
|
||||||
|
value={translation}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!isEditable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submittedTranslationRef.current = undefined
|
||||||
|
setTranslation(event.target.value)
|
||||||
|
setSaveState("idle")
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
isEditable &&
|
||||||
|
(event.metaKey || event.ctrlKey) &&
|
||||||
|
event.key === "Enter"
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
void handleSave()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message.comments.length > 0 && (
|
||||||
<footer className="i18n-message__meta">
|
<footer className="i18n-message__meta">
|
||||||
{message.comments.map((comment) => (
|
{message.comments.map((comment) => (
|
||||||
<span key={comment}>{comment}</span>
|
<span key={comment}>{comment}</span>
|
||||||
))}
|
))}
|
||||||
{message.origins.map((origin) => (
|
|
||||||
<span key={`${origin.file}:${origin.line ?? ""}`}>
|
|
||||||
{origin.file}
|
|
||||||
{origin.line ? `:${origin.line}` : ""}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</footer>
|
</footer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="i18n-message__actions">
|
{(isDirty || saveState !== "idle") && (
|
||||||
<span aria-live="polite" className="i18n-message__status">
|
<div className="i18n-message__actions">
|
||||||
{saveState === "saving" && "Saving…"}
|
<span
|
||||||
{saveState === "saved" && "Saved"}
|
aria-live="polite"
|
||||||
{saveState === "error" && "Could not save"}
|
className="i18n-message__status"
|
||||||
</span>
|
data-state={saveState}
|
||||||
<button
|
>
|
||||||
type="button"
|
{saveState === "saved" && (
|
||||||
disabled={!isDirty || saveState === "saving"}
|
<>
|
||||||
onClick={() => void handleSave()}
|
<CheckIcon aria-hidden="true" />
|
||||||
>
|
{messages.messagePanel.saved}
|
||||||
Save
|
</>
|
||||||
</button>
|
)}
|
||||||
</div>
|
{saveState === "error" && (
|
||||||
|
<>
|
||||||
|
<CircleAlertIcon aria-hidden="true" />
|
||||||
|
{messages.messagePanel.couldNotSave}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{isDirty && (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={handleReset}>
|
||||||
|
<RotateCcwIcon aria-hidden="true" />
|
||||||
|
{messages.messagePanel.reset}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="i18n-message__save"
|
||||||
|
disabled={saveState === "saving"}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
>
|
||||||
|
<SaveIcon aria-hidden="true" />
|
||||||
|
{saveState === "saving"
|
||||||
|
? messages.messagePanel.saving
|
||||||
|
: messages.messagePanel.save}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -141,57 +266,82 @@ function MessageEditor({
|
|||||||
export function MessagePanel({
|
export function MessagePanel({
|
||||||
className,
|
className,
|
||||||
locale,
|
locale,
|
||||||
|
refreshToken,
|
||||||
repository: repositoryProp,
|
repository: repositoryProp,
|
||||||
}: MessagePanelProps) {
|
}: MessagePanelProps) {
|
||||||
const repositoryFromContext = useOptionalMessageRepository()
|
const repositoryFromContext = useOptionalMessageRepository()
|
||||||
const repository = repositoryProp ?? repositoryFromContext
|
const repository = repositoryProp ?? repositoryFromContext
|
||||||
|
const { messages } = useDevtoolLocalization()
|
||||||
const [state, setState] = React.useState<LoadState>({ status: "loading" })
|
const [state, setState] = React.useState<LoadState>({ status: "loading" })
|
||||||
const [query, setQuery] = React.useState("")
|
const [query, setQuery] = React.useState("")
|
||||||
const [missingOnly, setMissingOnly] = React.useState(false)
|
const [missingOnly, setMissingOnly] = React.useState(false)
|
||||||
|
const [selectedNavigationKey, setSelectedNavigationKey] =
|
||||||
|
React.useState<string>()
|
||||||
|
const [openedNavigationKeys, setOpenedNavigationKeys] = React.useState<
|
||||||
|
readonly string[]
|
||||||
|
>([])
|
||||||
const loadRequestRef = React.useRef(0)
|
const loadRequestRef = React.useRef(0)
|
||||||
|
const previousLoadContextRef = React.useRef<{
|
||||||
|
locale: string
|
||||||
|
repository: MessageRepository | null
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
const loadMessages = React.useCallback(async () => {
|
const loadMessages = React.useCallback(
|
||||||
const requestId = loadRequestRef.current + 1
|
async ({ preserveContent = false } = {}) => {
|
||||||
loadRequestRef.current = requestId
|
const requestId = loadRequestRef.current + 1
|
||||||
|
loadRequestRef.current = requestId
|
||||||
|
|
||||||
if (!repository) {
|
if (!repository) {
|
||||||
setState({
|
setState({
|
||||||
error: new Error(
|
error: new Error(
|
||||||
"MessagePanel requires a MessageRepositoryProvider or a repository prop."
|
"MessagePanel requires a MessageRepositoryProvider or a repository prop."
|
||||||
),
|
),
|
||||||
status: "error",
|
status: "error",
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setState({ status: "loading" })
|
|
||||||
|
|
||||||
try {
|
|
||||||
const messages = await repository.getMessages(locale)
|
|
||||||
|
|
||||||
if (requestId !== loadRequestRef.current) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setState({ messages, status: "ready" })
|
if (!preserveContent) {
|
||||||
} catch (error) {
|
setState({ status: "loading" })
|
||||||
if (requestId !== loadRequestRef.current) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setState({
|
try {
|
||||||
error:
|
const [loadedMessages, packages] = await Promise.all([
|
||||||
error instanceof Error
|
repository.getMessages(locale),
|
||||||
? error
|
repository.getPackages?.() ?? Promise.resolve([]),
|
||||||
: new Error("Unable to load messages."),
|
])
|
||||||
status: "error",
|
|
||||||
})
|
if (requestId !== loadRequestRef.current) {
|
||||||
}
|
return
|
||||||
}, [locale, repository])
|
}
|
||||||
|
|
||||||
|
setState({ messages: loadedMessages, packages, status: "ready" })
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId !== loadRequestRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error("Unable to load messages."),
|
||||||
|
status: "error",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[locale, repository]
|
||||||
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void loadMessages()
|
const previousLoadContext = previousLoadContextRef.current
|
||||||
}, [loadMessages])
|
const preserveContent =
|
||||||
|
previousLoadContext?.locale === locale &&
|
||||||
|
previousLoadContext.repository === repository
|
||||||
|
|
||||||
|
previousLoadContextRef.current = { locale, repository }
|
||||||
|
void loadMessages({ preserveContent })
|
||||||
|
}, [loadMessages, locale, refreshToken, repository])
|
||||||
|
|
||||||
const visibleMessages = React.useMemo(() => {
|
const visibleMessages = React.useMemo(() => {
|
||||||
if (state.status !== "ready") {
|
if (state.status !== "ready") {
|
||||||
@@ -211,6 +361,92 @@ export function MessagePanel({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [missingOnly, query, state])
|
}, [missingOnly, query, state])
|
||||||
|
const visiblePackageGroups = React.useMemo(() => {
|
||||||
|
const groups = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
kind: DevtoolMessagePackage["kind"]
|
||||||
|
messages: DevtoolMessage[]
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
if (state.status === "ready" && query.trim().length === 0 && !missingOnly) {
|
||||||
|
for (const packageDefinition of state.packages) {
|
||||||
|
groups.set(packageDefinition.name, {
|
||||||
|
kind: packageDefinition.kind,
|
||||||
|
messages: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const message of visibleMessages) {
|
||||||
|
const group = groups.get(message.packageName)
|
||||||
|
|
||||||
|
if (group) {
|
||||||
|
group.messages.push(message)
|
||||||
|
} else {
|
||||||
|
groups.set(message.packageName, {
|
||||||
|
kind: "dependency",
|
||||||
|
messages: [message],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(groups, ([packageName, group]) => ({
|
||||||
|
kind: group.kind,
|
||||||
|
messages: group.messages,
|
||||||
|
missingCount: group.messages.filter((message) => message.missing).length,
|
||||||
|
packageName,
|
||||||
|
})).sort(
|
||||||
|
(left, right) =>
|
||||||
|
Number(right.kind === "application") -
|
||||||
|
Number(left.kind === "application") ||
|
||||||
|
left.packageName.localeCompare(right.packageName)
|
||||||
|
)
|
||||||
|
}, [missingOnly, query, state, visibleMessages])
|
||||||
|
const navigationModel = React.useMemo(
|
||||||
|
() => createMessageNavigationModel(visiblePackageGroups),
|
||||||
|
[visiblePackageGroups]
|
||||||
|
)
|
||||||
|
const navigationSelections = React.useMemo(
|
||||||
|
() => Array.from(navigationModel.selections.values()),
|
||||||
|
[navigationModel]
|
||||||
|
)
|
||||||
|
const activeSelection =
|
||||||
|
(selectedNavigationKey
|
||||||
|
? navigationModel.selections.get(selectedNavigationKey)
|
||||||
|
: undefined) ??
|
||||||
|
navigationSelections.find((selection) => selection.messages.length > 0) ??
|
||||||
|
navigationSelections[0]
|
||||||
|
const activeNavigationKey = activeSelection?.key
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!activeNavigationKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpenedNavigationKeys((current) =>
|
||||||
|
current.includes(activeNavigationKey)
|
||||||
|
? current
|
||||||
|
: [...current, activeNavigationKey]
|
||||||
|
)
|
||||||
|
}, [activeNavigationKey])
|
||||||
|
|
||||||
|
const openedSelections = React.useMemo(() => {
|
||||||
|
const activeKeys =
|
||||||
|
activeNavigationKey && !openedNavigationKeys.includes(activeNavigationKey)
|
||||||
|
? [...openedNavigationKeys, activeNavigationKey]
|
||||||
|
: openedNavigationKeys
|
||||||
|
|
||||||
|
return activeKeys.flatMap((key) => {
|
||||||
|
const selection = navigationModel.selections.get(key)
|
||||||
|
return selection ? [selection] : []
|
||||||
|
})
|
||||||
|
}, [activeNavigationKey, navigationModel, openedNavigationKeys])
|
||||||
|
const missingMessageCount =
|
||||||
|
state.status === "ready"
|
||||||
|
? state.messages.filter((message) => message.missing).length
|
||||||
|
: 0
|
||||||
|
|
||||||
const updateMessage = React.useCallback(
|
const updateMessage = React.useCallback(
|
||||||
async (message: DevtoolMessage, translation: string) => {
|
async (message: DevtoolMessage, translation: string) => {
|
||||||
@@ -218,6 +454,10 @@ export function MessagePanel({
|
|||||||
throw new Error("No message repository is available.")
|
throw new Error("No message repository is available.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!message.editable) {
|
||||||
|
throw new Error("This source message is read-only.")
|
||||||
|
}
|
||||||
|
|
||||||
const updatedMessage = await repository.updateMessage({
|
const updatedMessage = await repository.updateMessage({
|
||||||
catalog: message.catalog,
|
catalog: message.catalog,
|
||||||
id: message.id,
|
id: message.id,
|
||||||
@@ -237,6 +477,7 @@ export function MessagePanel({
|
|||||||
? updatedMessage
|
? updatedMessage
|
||||||
: item
|
: item
|
||||||
),
|
),
|
||||||
|
packages: current.packages,
|
||||||
status: "ready",
|
status: "ready",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -247,18 +488,39 @@ export function MessagePanel({
|
|||||||
const rootClassName = ["i18n-message-panel", className]
|
const rootClassName = ["i18n-message-panel", className]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")
|
.join(" ")
|
||||||
|
const emptyStateMessage =
|
||||||
|
state.status !== "ready" || visibleMessages.length > 0
|
||||||
|
? undefined
|
||||||
|
: state.messages.length === 0
|
||||||
|
? messages.messagePanel.emptyCatalog
|
||||||
|
: query.trim().length > 0
|
||||||
|
? messages.messagePanel.noSearchResults
|
||||||
|
: missingOnly
|
||||||
|
? messages.messagePanel.noMissingMessages
|
||||||
|
: messages.messagePanel.noMatches
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={rootClassName}>
|
<section className={rootClassName}>
|
||||||
<header className="i18n-message-panel__toolbar">
|
<header className="i18n-message-panel__toolbar">
|
||||||
<label className="i18n-message-panel__search">
|
<label className="i18n-message-panel__search">
|
||||||
<span className="i18n-visually-hidden">Search messages</span>
|
<span className="i18n-visually-hidden">
|
||||||
|
{messages.messagePanel.search}
|
||||||
|
</span>
|
||||||
|
<SearchIcon aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search messages"
|
placeholder={messages.messagePanel.search}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{state.status === "ready" && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="i18n-message-panel__result-count"
|
||||||
|
>
|
||||||
|
{visibleMessages.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label className="i18n-message-panel__toggle">
|
<label className="i18n-message-panel__toggle">
|
||||||
<input
|
<input
|
||||||
@@ -266,40 +528,83 @@ export function MessagePanel({
|
|||||||
checked={missingOnly}
|
checked={missingOnly}
|
||||||
onChange={(event) => setMissingOnly(event.target.checked)}
|
onChange={(event) => setMissingOnly(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
Missing only
|
<span aria-hidden="true" className="i18n-message-panel__checkbox">
|
||||||
|
<CheckIcon />
|
||||||
|
</span>
|
||||||
|
<span>{messages.messagePanel.missingOnly}</span>
|
||||||
|
{state.status === "ready" && (
|
||||||
|
<span aria-hidden="true" className="i18n-message-panel__count">
|
||||||
|
{missingMessageCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
{state.status === "ready" && (
|
|
||||||
<output className="i18n-message-panel__count">
|
|
||||||
{visibleMessages.length} / {state.messages.length}
|
|
||||||
</output>
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{state.status === "loading" ? (
|
{state.status === "loading" ? (
|
||||||
<div className="i18n-message-panel__state" role="status">
|
<div className="i18n-message-panel__state" role="status">
|
||||||
Loading {locale}…
|
{messages.messagePanel.loading(locale)}
|
||||||
</div>
|
</div>
|
||||||
) : state.status === "error" ? (
|
) : state.status === "error" ? (
|
||||||
<div className="i18n-message-panel__state" role="alert">
|
<div className="i18n-message-panel__state" role="alert">
|
||||||
<p>{state.error.message}</p>
|
<p>{state.error.message}</p>
|
||||||
<button type="button" onClick={() => void loadMessages()}>
|
<button type="button" onClick={() => void loadMessages()}>
|
||||||
Retry
|
{messages.messagePanel.retry}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : visibleMessages.length === 0 ? (
|
) : visiblePackageGroups.length === 0 ? (
|
||||||
<div className="i18n-message-panel__state">
|
<div className="i18n-message-panel__state">{emptyStateMessage}</div>
|
||||||
No messages match the current filters.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="i18n-message-panel__list">
|
<div className="i18n-message-workspace">
|
||||||
{visibleMessages.map((message) => (
|
<MessageNavigation
|
||||||
<MessageEditor
|
model={navigationModel}
|
||||||
key={`${message.catalog}:${message.id}`}
|
selectedKey={activeNavigationKey}
|
||||||
locale={locale}
|
onSelect={setSelectedNavigationKey}
|
||||||
message={message}
|
/>
|
||||||
onSave={(translation) => updateMessage(message, translation)}
|
<section
|
||||||
/>
|
aria-label={messages.messagePanel.messageEditor}
|
||||||
))}
|
className="i18n-message-workspace__editor"
|
||||||
|
>
|
||||||
|
{activeSelection ? (
|
||||||
|
openedSelections.map((selection) => {
|
||||||
|
return (
|
||||||
|
<React.Activity
|
||||||
|
key={selection.key}
|
||||||
|
mode={
|
||||||
|
selection.key === activeNavigationKey
|
||||||
|
? "visible"
|
||||||
|
: "hidden"
|
||||||
|
}
|
||||||
|
name={selection.label}
|
||||||
|
>
|
||||||
|
{selection.messages.length > 0 ? (
|
||||||
|
<div className="i18n-message-workspace__editor-list">
|
||||||
|
{selection.messages.map((message) => (
|
||||||
|
<MessageEditor
|
||||||
|
key={`${message.catalog}:${message.id}`}
|
||||||
|
locale={locale}
|
||||||
|
message={message}
|
||||||
|
onSave={(translation) =>
|
||||||
|
updateMessage(message, translation)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="i18n-message-workspace__empty">
|
||||||
|
{visibleMessages.length === 0
|
||||||
|
? messages.messagePanel.emptyPackage
|
||||||
|
: messages.messagePanel.selectMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</React.Activity>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="i18n-message-workspace__empty">
|
||||||
|
{messages.messagePanel.selectMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,14 +6,22 @@ export interface MessageOrigin {
|
|||||||
export interface DevtoolMessage {
|
export interface DevtoolMessage {
|
||||||
catalog: string
|
catalog: string
|
||||||
comments: readonly string[]
|
comments: readonly string[]
|
||||||
|
editable: boolean
|
||||||
id: string
|
id: string
|
||||||
missing: boolean
|
missing: boolean
|
||||||
obsolete: boolean
|
obsolete: boolean
|
||||||
origins: readonly MessageOrigin[]
|
origins: readonly MessageOrigin[]
|
||||||
|
packageName: string
|
||||||
source: string
|
source: string
|
||||||
|
sourceLocale: string
|
||||||
translation: string
|
translation: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DevtoolMessagePackage {
|
||||||
|
kind: "application" | "dependency"
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateMessageInput {
|
export interface UpdateMessageInput {
|
||||||
catalog: string
|
catalog: string
|
||||||
id: string
|
id: string
|
||||||
@@ -23,5 +31,6 @@ export interface UpdateMessageInput {
|
|||||||
|
|
||||||
export interface MessageRepository {
|
export interface MessageRepository {
|
||||||
getMessages(locale: string): Promise<readonly DevtoolMessage[]>
|
getMessages(locale: string): Promise<readonly DevtoolMessage[]>
|
||||||
|
getPackages?(): Promise<readonly DevtoolMessagePackage[]>
|
||||||
updateMessage(input: UpdateMessageInput): Promise<DevtoolMessage>
|
updateMessage(input: UpdateMessageInput): Promise<DevtoolMessage>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
type TriggerDockEdge = "bottom" | "left" | "right" | "top"
|
||||||
|
|
||||||
|
interface TriggerPosition {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TriggerDockPlacement {
|
||||||
|
edge: TriggerDockEdge
|
||||||
|
offset: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TriggerDragSession {
|
||||||
|
height: number
|
||||||
|
lastPosition: TriggerPosition
|
||||||
|
longPressed: boolean
|
||||||
|
longPressTimer: number | null
|
||||||
|
moved: boolean
|
||||||
|
offsetX: number
|
||||||
|
offsetY: number
|
||||||
|
pointerId: number
|
||||||
|
previousDockPlacement: TriggerDockPlacement | null
|
||||||
|
previousPosition: TriggerPosition | null
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
width: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredTriggerPlacement {
|
||||||
|
dockPlacement: TriggerDockPlacement | null
|
||||||
|
position: TriggerPosition | null
|
||||||
|
version: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFloatingTriggerOptions {
|
||||||
|
storageKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRIGGER_DEFAULT_INSET = 16
|
||||||
|
const TRIGGER_DOCK_THRESHOLD = 32
|
||||||
|
const TRIGGER_DRAG_THRESHOLD = 4
|
||||||
|
const TRIGGER_LONG_PRESS_DELAY = 400
|
||||||
|
const DEFAULT_STORAGE_KEY = "i18n-devtool:floating-trigger-placement"
|
||||||
|
const DEFAULT_PLACEMENT: StoredTriggerPlacement = {
|
||||||
|
dockPlacement: {
|
||||||
|
edge: "right",
|
||||||
|
offset: null,
|
||||||
|
},
|
||||||
|
position: null,
|
||||||
|
version: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, minimum: number, maximum: number) {
|
||||||
|
return Math.min(Math.max(value, minimum), Math.max(minimum, maximum))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDockEdge(value: unknown): value is TriggerDockEdge {
|
||||||
|
return (
|
||||||
|
value === "bottom" ||
|
||||||
|
value === "left" ||
|
||||||
|
value === "right" ||
|
||||||
|
value === "top"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoredPlacement(value: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as Partial<StoredTriggerPlacement>
|
||||||
|
const rawDockPlacement = parsed.dockPlacement
|
||||||
|
const rawPosition = parsed.position
|
||||||
|
const dockPlacement =
|
||||||
|
rawDockPlacement === null
|
||||||
|
? null
|
||||||
|
: rawDockPlacement &&
|
||||||
|
isDockEdge(rawDockPlacement.edge) &&
|
||||||
|
(rawDockPlacement.offset === null ||
|
||||||
|
(typeof rawDockPlacement.offset === "number" &&
|
||||||
|
Number.isFinite(rawDockPlacement.offset)))
|
||||||
|
? {
|
||||||
|
edge: rawDockPlacement.edge,
|
||||||
|
offset: rawDockPlacement.offset,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
const position =
|
||||||
|
rawPosition === null
|
||||||
|
? null
|
||||||
|
: rawPosition &&
|
||||||
|
typeof rawPosition.x === "number" &&
|
||||||
|
Number.isFinite(rawPosition.x) &&
|
||||||
|
typeof rawPosition.y === "number" &&
|
||||||
|
Number.isFinite(rawPosition.y)
|
||||||
|
? { x: rawPosition.x, y: rawPosition.y }
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsed.version !== 1 ||
|
||||||
|
dockPlacement === undefined ||
|
||||||
|
position === undefined
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dockPlacement,
|
||||||
|
position,
|
||||||
|
version: 1,
|
||||||
|
} satisfies StoredTriggerPlacement
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredPlacement(storageKey: string) {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return parseStoredPlacement(window.localStorage.getItem(storageKey))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDockPlacement(
|
||||||
|
position: TriggerPosition,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): TriggerDockPlacement | null {
|
||||||
|
const distances: readonly [TriggerDockEdge, number][] = [
|
||||||
|
["left", position.x],
|
||||||
|
["right", window.innerWidth - position.x - width],
|
||||||
|
["top", position.y],
|
||||||
|
["bottom", window.innerHeight - position.y - height],
|
||||||
|
]
|
||||||
|
const [edge, distance] = distances.reduce((nearest, candidate) =>
|
||||||
|
candidate[1] < nearest[1] ? candidate : nearest
|
||||||
|
)
|
||||||
|
|
||||||
|
if (distance > TRIGGER_DOCK_THRESHOLD) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
edge,
|
||||||
|
offset: edge === "left" || edge === "right" ? position.y : position.x,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTriggerStyle(
|
||||||
|
dockPlacement: TriggerDockPlacement | null,
|
||||||
|
position: TriggerPosition | null
|
||||||
|
): React.CSSProperties | undefined {
|
||||||
|
if (!dockPlacement) {
|
||||||
|
return position
|
||||||
|
? {
|
||||||
|
left: position.x,
|
||||||
|
top: position.y,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const { edge, offset } = dockPlacement
|
||||||
|
|
||||||
|
if (edge === "left" || edge === "right") {
|
||||||
|
return {
|
||||||
|
[edge]: 0,
|
||||||
|
...(offset === null
|
||||||
|
? { bottom: TRIGGER_DEFAULT_INSET }
|
||||||
|
: { top: offset }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
[edge]: 0,
|
||||||
|
...(offset === null ? { right: TRIGGER_DEFAULT_INSET } : { left: offset }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFloatingTrigger({
|
||||||
|
storageKey = DEFAULT_STORAGE_KEY,
|
||||||
|
}: UseFloatingTriggerOptions = {}) {
|
||||||
|
const [initialPlacement] = React.useState(
|
||||||
|
() => readStoredPlacement(storageKey) ?? DEFAULT_PLACEMENT
|
||||||
|
)
|
||||||
|
const triggerRef = React.useRef<HTMLButtonElement>(null)
|
||||||
|
const dragSessionRef = React.useRef<TriggerDragSession | null>(null)
|
||||||
|
const suppressClickRef = React.useRef(false)
|
||||||
|
const [dockPlacement, setDockPlacement] =
|
||||||
|
React.useState<TriggerDockPlacement | null>(initialPlacement.dockPlacement)
|
||||||
|
const [position, setPosition] = React.useState<TriggerPosition | null>(
|
||||||
|
initialPlacement.position
|
||||||
|
)
|
||||||
|
const [isDragging, setDragging] = React.useState(false)
|
||||||
|
const [isHovered, setHovered] = React.useState(false)
|
||||||
|
const [isFocused, setFocused] = React.useState(false)
|
||||||
|
|
||||||
|
React.useEffect(
|
||||||
|
() => () => {
|
||||||
|
const timer = dragSessionRef.current?.longPressTimer
|
||||||
|
|
||||||
|
if (timer !== null && timer !== undefined) {
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const keepTriggerInViewport = () => {
|
||||||
|
const trigger = triggerRef.current
|
||||||
|
|
||||||
|
if (!trigger || trigger.hidden) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width, height } = trigger.getBoundingClientRect()
|
||||||
|
|
||||||
|
setPosition((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
x: clamp(current.x, 0, window.innerWidth - width),
|
||||||
|
y: clamp(current.y, 0, window.innerHeight - height),
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
)
|
||||||
|
setDockPlacement((current) => {
|
||||||
|
if (!current || current.offset === null) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
const maximumOffset =
|
||||||
|
current.edge === "left" || current.edge === "right"
|
||||||
|
? window.innerHeight - height
|
||||||
|
: window.innerWidth - width
|
||||||
|
const offset = clamp(current.offset, 0, maximumOffset)
|
||||||
|
|
||||||
|
return offset === current.offset ? current : { ...current, offset }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
keepTriggerInViewport()
|
||||||
|
window.addEventListener("resize", keepTriggerInViewport)
|
||||||
|
return () => window.removeEventListener("resize", keepTriggerInViewport)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
storageKey,
|
||||||
|
JSON.stringify({
|
||||||
|
dockPlacement,
|
||||||
|
position,
|
||||||
|
version: 1,
|
||||||
|
} satisfies StoredTriggerPlacement)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Storage can be unavailable in privacy-restricted browser contexts.
|
||||||
|
}
|
||||||
|
}, [dockPlacement, isDragging, position, storageKey])
|
||||||
|
|
||||||
|
const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const trigger = event.currentTarget
|
||||||
|
const rect = trigger.getBoundingClientRect()
|
||||||
|
const nextPosition = { x: rect.left, y: rect.top }
|
||||||
|
|
||||||
|
const session: TriggerDragSession = {
|
||||||
|
height: rect.height,
|
||||||
|
lastPosition: nextPosition,
|
||||||
|
longPressed: false,
|
||||||
|
longPressTimer: null,
|
||||||
|
moved: false,
|
||||||
|
offsetX: event.clientX - rect.left,
|
||||||
|
offsetY: event.clientY - rect.top,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
previousDockPlacement: dockPlacement,
|
||||||
|
previousPosition: position,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
width: rect.width,
|
||||||
|
}
|
||||||
|
session.longPressTimer = window.setTimeout(() => {
|
||||||
|
if (dragSessionRef.current !== session || session.moved) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.longPressed = true
|
||||||
|
session.longPressTimer = null
|
||||||
|
setDragging(true)
|
||||||
|
}, TRIGGER_LONG_PRESS_DELAY)
|
||||||
|
dragSessionRef.current = session
|
||||||
|
trigger.setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerMove = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
const session = dragSessionRef.current
|
||||||
|
|
||||||
|
if (!session || session.pointerId !== event.pointerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let startedDragging = false
|
||||||
|
|
||||||
|
if (
|
||||||
|
!session.moved &&
|
||||||
|
Math.hypot(
|
||||||
|
event.clientX - session.startX,
|
||||||
|
event.clientY - session.startY
|
||||||
|
) >= TRIGGER_DRAG_THRESHOLD
|
||||||
|
) {
|
||||||
|
session.moved = true
|
||||||
|
startedDragging = true
|
||||||
|
|
||||||
|
if (session.longPressTimer !== null) {
|
||||||
|
window.clearTimeout(session.longPressTimer)
|
||||||
|
session.longPressTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.moved) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const nextPosition = {
|
||||||
|
x: clamp(
|
||||||
|
event.clientX - session.offsetX,
|
||||||
|
0,
|
||||||
|
window.innerWidth - session.width
|
||||||
|
),
|
||||||
|
y: clamp(
|
||||||
|
event.clientY - session.offsetY,
|
||||||
|
0,
|
||||||
|
window.innerHeight - session.height
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
session.lastPosition = nextPosition
|
||||||
|
|
||||||
|
if (startedDragging) {
|
||||||
|
setDockPlacement(null)
|
||||||
|
setDragging(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosition(nextPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishPointerInteraction = (
|
||||||
|
event: React.PointerEvent<HTMLButtonElement>,
|
||||||
|
canceled: boolean
|
||||||
|
) => {
|
||||||
|
const session = dragSessionRef.current
|
||||||
|
|
||||||
|
if (!session || session.pointerId !== event.pointerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dragSessionRef.current = null
|
||||||
|
setDragging(false)
|
||||||
|
|
||||||
|
if (session.longPressTimer !== null) {
|
||||||
|
window.clearTimeout(session.longPressTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canceled && session.moved) {
|
||||||
|
setDockPlacement(session.previousDockPlacement)
|
||||||
|
setPosition(session.previousPosition)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.moved) {
|
||||||
|
if (session.longPressed && !canceled) {
|
||||||
|
setFocused(false)
|
||||||
|
|
||||||
|
if (document.activeElement === event.currentTarget) {
|
||||||
|
event.currentTarget.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
suppressClickRef.current = true
|
||||||
|
window.setTimeout(() => {
|
||||||
|
suppressClickRef.current = false
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDockPlacement(
|
||||||
|
resolveDockPlacement(session.lastPosition, session.width, session.height)
|
||||||
|
)
|
||||||
|
setFocused(false)
|
||||||
|
|
||||||
|
if (document.activeElement === event.currentTarget) {
|
||||||
|
event.currentTarget.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
suppressClickRef.current = true
|
||||||
|
window.setTimeout(() => {
|
||||||
|
suppressClickRef.current = false
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCollapsed =
|
||||||
|
dockPlacement !== null && !isDragging && !isHovered && !isFocused
|
||||||
|
|
||||||
|
return {
|
||||||
|
dockEdge: dockPlacement?.edge,
|
||||||
|
handleBlur: () => setFocused(false),
|
||||||
|
handleClick: () => {
|
||||||
|
if (suppressClickRef.current) {
|
||||||
|
suppressClickRef.current = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
setFocused(false)
|
||||||
|
setHovered(false)
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
handleFocus: () => setFocused(true),
|
||||||
|
handlePointerCancel: (event: React.PointerEvent<HTMLButtonElement>) =>
|
||||||
|
finishPointerInteraction(event, true),
|
||||||
|
handlePointerDown,
|
||||||
|
handlePointerEnter: () => setHovered(true),
|
||||||
|
handlePointerLeave: () => setHovered(false),
|
||||||
|
handlePointerMove,
|
||||||
|
handlePointerUp: (event: React.PointerEvent<HTMLButtonElement>) =>
|
||||||
|
finishPointerInteraction(event, false),
|
||||||
|
isCollapsed,
|
||||||
|
isDragging,
|
||||||
|
style: getTriggerStyle(dockPlacement, position),
|
||||||
|
triggerRef,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { MessageCatalog } from "./types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This module is replaced by the @workspace/i18n Vite plugin.
|
||||||
|
*/
|
||||||
|
export function loadMessageCatalog(_locale: string): Promise<MessageCatalog> {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error(
|
||||||
|
"The i18n catalog loader requires the @workspace/i18n Vite plugin."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { MessageCatalogs } from "./types"
|
||||||
|
|
||||||
|
export type MessageCatalogSource = MessageCatalogs | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines package and application catalogs by locale. Later sources override
|
||||||
|
* earlier ones, allowing an application to customize built-in translations.
|
||||||
|
*/
|
||||||
|
export function mergeMessageCatalogs(
|
||||||
|
...sources: readonly MessageCatalogSource[]
|
||||||
|
): MessageCatalogs {
|
||||||
|
const catalogs: MessageCatalogs = {}
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!source) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [locale, messages] of Object.entries(source)) {
|
||||||
|
catalogs[locale] = {
|
||||||
|
...catalogs[locale],
|
||||||
|
...messages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return catalogs
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import type { AllMessages } from "@lingui/core"
|
|
||||||
|
|
||||||
import type { LocaleDefinition } from "./types"
|
import type { IntlFormatters, LocaleDefinition, MessageCatalogs } from "./types"
|
||||||
|
|
||||||
export interface I18nRuntimeContextValue {
|
export interface I18nRuntimeContextValue {
|
||||||
catalogs: AllMessages
|
catalogs: MessageCatalogs
|
||||||
locale: string
|
formatters: IntlFormatters
|
||||||
|
locale: LocaleDefinition
|
||||||
locales: readonly LocaleDefinition[]
|
locales: readonly LocaleDefinition[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +1,87 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import type { MessageDescriptor, MessageId, MessageOptions } from "@lingui/core"
|
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
|
|
||||||
import { I18nRuntimeContext } from "./context"
|
import { I18nRuntimeContext } from "./context"
|
||||||
import type { LocaleDefinition, TranslateFunction } from "./types"
|
import { createIntlFormatters } from "./intl-formatters"
|
||||||
|
import type {
|
||||||
|
IntlFormatters,
|
||||||
|
LocaleDefinition,
|
||||||
|
MessageDescriptor,
|
||||||
|
MessageId,
|
||||||
|
MessageOptions,
|
||||||
|
MessageValues,
|
||||||
|
TranslateFunction,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
export function useTranslate(): TranslateFunction {
|
export function useTranslate(): TranslateFunction {
|
||||||
return useLingui()._
|
const { i18n } = useLingui()
|
||||||
|
|
||||||
|
return React.useCallback(
|
||||||
|
(descriptor, values) =>
|
||||||
|
i18n._({
|
||||||
|
...descriptor,
|
||||||
|
values: values ?? descriptor.values,
|
||||||
|
}),
|
||||||
|
[i18n]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMessage(descriptor: MessageDescriptor): string
|
export function useMessage(descriptor: MessageDescriptor): string
|
||||||
export function useMessage(
|
export function useMessage(
|
||||||
id: MessageId,
|
id: MessageId,
|
||||||
values?: Record<string, unknown>,
|
values?: MessageValues,
|
||||||
options?: MessageOptions
|
options?: MessageOptions
|
||||||
): string
|
): string
|
||||||
export function useMessage(
|
export function useMessage(
|
||||||
descriptorOrId: MessageDescriptor | MessageId,
|
descriptorOrId: MessageDescriptor | MessageId,
|
||||||
values?: Record<string, unknown>,
|
values?: MessageValues,
|
||||||
options?: MessageOptions
|
options?: MessageOptions
|
||||||
) {
|
) {
|
||||||
const translate = useTranslate()
|
const { i18n } = useLingui()
|
||||||
|
|
||||||
return React.useMemo(
|
return React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
typeof descriptorOrId === "string"
|
typeof descriptorOrId === "string"
|
||||||
? translate(descriptorOrId, values, options)
|
? i18n._(descriptorOrId, values, options)
|
||||||
: translate(descriptorOrId),
|
: i18n._({
|
||||||
[descriptorOrId, options, translate, values]
|
...descriptorOrId,
|
||||||
|
values: values ?? descriptorOrId.values,
|
||||||
|
}),
|
||||||
|
[descriptorOrId, i18n, options, values]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLocale(): string {
|
export function useLocale(): string {
|
||||||
const runtime = React.useContext(I18nRuntimeContext)
|
return useLocaleDefinition().locale
|
||||||
const { i18n } = useLingui()
|
|
||||||
|
|
||||||
return runtime?.locale ?? i18n.locale
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLocales(): readonly LocaleDefinition[] {
|
export function useLocaleDefinition(): LocaleDefinition {
|
||||||
const runtime = React.useContext(I18nRuntimeContext)
|
const runtime = React.useContext(I18nRuntimeContext)
|
||||||
const { i18n } = useLingui()
|
const { i18n } = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
runtime?.locales ?? [
|
runtime?.locale ?? {
|
||||||
{
|
label: i18n.locale,
|
||||||
direction: "ltr",
|
languageTag: i18n.locale,
|
||||||
label: i18n.locale,
|
locale: i18n.locale,
|
||||||
locale: i18n.locale,
|
}
|
||||||
},
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useLocales(): readonly LocaleDefinition[] {
|
||||||
|
const runtime = React.useContext(I18nRuntimeContext)
|
||||||
|
const activeLocale = useLocaleDefinition()
|
||||||
|
|
||||||
|
return runtime?.locales ?? [activeLocale]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFormatters(): IntlFormatters {
|
||||||
|
const runtime = React.useContext(I18nRuntimeContext)
|
||||||
|
const locale = useLocaleDefinition()
|
||||||
|
const fallbackFormatters = React.useMemo(
|
||||||
|
() => createIntlFormatters(locale.languageTag ?? locale.locale),
|
||||||
|
[locale.languageTag, locale.locale]
|
||||||
|
)
|
||||||
|
|
||||||
|
return runtime?.formatters ?? fallbackFormatters
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { setupI18n, type AllMessages, type I18n } from "@lingui/core"
|
|
||||||
import {
|
import {
|
||||||
I18nProvider as LinguiI18nProvider,
|
setupI18n,
|
||||||
type I18nProviderProps as LinguiI18nProviderProps,
|
type AllMessages as LinguiMessageCatalogs,
|
||||||
} from "@lingui/react"
|
} from "@lingui/core"
|
||||||
|
import { I18nProvider as LinguiI18nProvider } from "@lingui/react"
|
||||||
|
|
||||||
import { I18nRuntimeContext } from "./context"
|
import { I18nRuntimeContext } from "./context"
|
||||||
import type { LocaleDefinition, LocaleDirection, LocaleInput } from "./types"
|
import { mergeMessageCatalogs, type MessageCatalogSource } from "./catalogs"
|
||||||
|
import { createIntlFormatters } from "./intl-formatters"
|
||||||
|
import type {
|
||||||
|
LocaleDefinition,
|
||||||
|
LocaleDirection,
|
||||||
|
LocaleInput,
|
||||||
|
MessageCatalogs,
|
||||||
|
MissingTranslationHandler,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
const RTL_LANGUAGES = new Set([
|
const RTL_LANGUAGES = new Set([
|
||||||
"ar",
|
"ar",
|
||||||
@@ -28,10 +36,22 @@ function inferLocaleDirection(locale: string): LocaleDirection {
|
|||||||
return language && RTL_LANGUAGES.has(language) ? "rtl" : "ltr"
|
return language && RTL_LANGUAGES.has(language) ? "rtl" : "ltr"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLocaleDisplayName(languageTag: string, fallback: string) {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
new Intl.DisplayNames([languageTag], { type: "language" }).of(
|
||||||
|
languageTag
|
||||||
|
) ?? fallback
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLocales(
|
function normalizeLocales(
|
||||||
locale: string,
|
locale: string,
|
||||||
locales: readonly LocaleInput[] | undefined,
|
locales: readonly LocaleInput[] | undefined,
|
||||||
catalogs: AllMessages
|
catalogs: MessageCatalogs
|
||||||
): readonly LocaleDefinition[] {
|
): readonly LocaleDefinition[] {
|
||||||
const inputs =
|
const inputs =
|
||||||
locales && locales.length > 0
|
locales && locales.length > 0
|
||||||
@@ -40,12 +60,15 @@ function normalizeLocales(
|
|||||||
|
|
||||||
const definitions = inputs.map<LocaleDefinition>((input) => {
|
const definitions = inputs.map<LocaleDefinition>((input) => {
|
||||||
const definition = typeof input === "string" ? { locale: input } : input
|
const definition = typeof input === "string" ? { locale: input } : input
|
||||||
|
const languageTag = definition.languageTag ?? definition.locale
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...definition,
|
...definition,
|
||||||
direction:
|
direction: definition.direction ?? inferLocaleDirection(languageTag),
|
||||||
definition.direction ?? inferLocaleDirection(definition.locale),
|
label:
|
||||||
label: definition.label ?? definition.locale,
|
definition.label ??
|
||||||
|
getLocaleDisplayName(languageTag, definition.locale),
|
||||||
|
languageTag,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -54,7 +77,8 @@ function normalizeLocales(
|
|||||||
...definitions,
|
...definitions,
|
||||||
{
|
{
|
||||||
direction: inferLocaleDirection(locale),
|
direction: inferLocaleDirection(locale),
|
||||||
label: locale,
|
label: getLocaleDisplayName(locale, locale),
|
||||||
|
languageTag: locale,
|
||||||
locale,
|
locale,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -63,60 +87,80 @@ function normalizeLocales(
|
|||||||
return definitions
|
return definitions
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface I18nProviderProps extends Pick<
|
export interface I18nProviderProps {
|
||||||
LinguiI18nProviderProps,
|
catalogs?: MessageCatalogSource | readonly MessageCatalogSource[]
|
||||||
"children" | "defaultComponent"
|
children?: React.ReactNode
|
||||||
> {
|
|
||||||
catalogs?: AllMessages
|
|
||||||
i18n?: I18n
|
|
||||||
locale: string
|
locale: string
|
||||||
locales?: readonly LocaleInput[]
|
locales?: readonly LocaleInput[]
|
||||||
missing?: ConstructorParameters<typeof I18n>[0]["missing"]
|
missing?: MissingTranslationHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCatalogSources(
|
||||||
|
catalogs: I18nProviderProps["catalogs"]
|
||||||
|
): readonly MessageCatalogSource[] {
|
||||||
|
if (!catalogs) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.isArray(catalogs) ? catalogs : [catalogs as MessageCatalogSource]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function I18nProvider({
|
export function I18nProvider({
|
||||||
catalogs = {},
|
catalogs,
|
||||||
children,
|
children,
|
||||||
defaultComponent,
|
|
||||||
i18n: providedI18n,
|
|
||||||
locale,
|
locale,
|
||||||
locales,
|
locales,
|
||||||
missing,
|
missing,
|
||||||
}: I18nProviderProps) {
|
}: I18nProviderProps) {
|
||||||
const i18n = React.useMemo(() => {
|
const mergedCatalogs = React.useMemo(
|
||||||
const instance =
|
() => mergeMessageCatalogs(...normalizeCatalogSources(catalogs)),
|
||||||
providedI18n ??
|
[catalogs]
|
||||||
|
)
|
||||||
|
const normalizedLocales = React.useMemo(
|
||||||
|
() => normalizeLocales(locale, locales, mergedCatalogs),
|
||||||
|
[locale, locales, mergedCatalogs]
|
||||||
|
)
|
||||||
|
const activeLocale = React.useMemo(
|
||||||
|
() =>
|
||||||
|
normalizedLocales.find((definition) => definition.locale === locale) ?? {
|
||||||
|
direction: inferLocaleDirection(locale),
|
||||||
|
label: getLocaleDisplayName(locale, locale),
|
||||||
|
languageTag: locale,
|
||||||
|
locale,
|
||||||
|
},
|
||||||
|
[locale, normalizedLocales]
|
||||||
|
)
|
||||||
|
const formattingLocales = React.useMemo(
|
||||||
|
() => [activeLocale.languageTag ?? activeLocale.locale],
|
||||||
|
[activeLocale]
|
||||||
|
)
|
||||||
|
const i18n = React.useMemo(
|
||||||
|
() =>
|
||||||
setupI18n({
|
setupI18n({
|
||||||
locale,
|
locale,
|
||||||
messages: catalogs,
|
locales: formattingLocales,
|
||||||
|
messages: mergedCatalogs as LinguiMessageCatalogs,
|
||||||
missing,
|
missing,
|
||||||
})
|
}),
|
||||||
|
[formattingLocales, locale, mergedCatalogs, missing]
|
||||||
if (providedI18n) {
|
)
|
||||||
providedI18n.load(catalogs)
|
const formatters = React.useMemo(
|
||||||
providedI18n.activate(locale)
|
() => createIntlFormatters(formattingLocales),
|
||||||
}
|
[formattingLocales]
|
||||||
|
|
||||||
return instance
|
|
||||||
}, [catalogs, locale, missing, providedI18n])
|
|
||||||
const normalizedLocales = React.useMemo(
|
|
||||||
() => normalizeLocales(locale, locales, catalogs),
|
|
||||||
[catalogs, locale, locales]
|
|
||||||
)
|
)
|
||||||
const contextValue = React.useMemo(
|
const contextValue = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
catalogs,
|
catalogs: mergedCatalogs,
|
||||||
locale,
|
formatters,
|
||||||
|
locale: activeLocale,
|
||||||
locales: normalizedLocales,
|
locales: normalizedLocales,
|
||||||
}),
|
}),
|
||||||
[catalogs, locale, normalizedLocales]
|
[activeLocale, formatters, mergedCatalogs, normalizedLocales]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<I18nRuntimeContext.Provider value={contextValue}>
|
<I18nRuntimeContext.Provider value={contextValue}>
|
||||||
<LinguiI18nProvider i18n={i18n} defaultComponent={defaultComponent}>
|
<LinguiI18nProvider i18n={i18n}>{children}</LinguiI18nProvider>
|
||||||
{children}
|
|
||||||
</LinguiI18nProvider>
|
|
||||||
</I18nRuntimeContext.Provider>
|
</I18nRuntimeContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
export { I18nProvider, type I18nProviderProps } from "./i18n-provider"
|
export { I18nProvider, type I18nProviderProps } from "./i18n-provider"
|
||||||
export { Translate, type TranslateProps } from "./translate"
|
export { Translate, type TranslateProps } from "./translate"
|
||||||
export { useLocale, useLocales, useMessage, useTranslate } from "./hooks"
|
export { createIntlFormatters } from "./intl-formatters"
|
||||||
|
export { mergeMessageCatalogs, type MessageCatalogSource } from "./catalogs"
|
||||||
|
export {
|
||||||
|
useFormatters,
|
||||||
|
useLocale,
|
||||||
|
useLocaleDefinition,
|
||||||
|
useLocales,
|
||||||
|
useMessage,
|
||||||
|
useTranslate,
|
||||||
|
} from "./hooks"
|
||||||
export type {
|
export type {
|
||||||
AllMessages,
|
IntlFormatters,
|
||||||
I18n,
|
|
||||||
LocaleDefinition,
|
LocaleDefinition,
|
||||||
LocaleDirection,
|
LocaleDirection,
|
||||||
LocaleInput,
|
LocaleInput,
|
||||||
|
MessageCatalog,
|
||||||
|
MessageCatalogs,
|
||||||
MessageDescriptor,
|
MessageDescriptor,
|
||||||
MessageId,
|
MessageId,
|
||||||
MessageOptions,
|
MessageOptions,
|
||||||
Messages,
|
MessageValues,
|
||||||
|
MissingTranslationHandler,
|
||||||
TranslateFunction,
|
TranslateFunction,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { IntlFormatters } from "./types"
|
||||||
|
|
||||||
|
function createFormatterCache<TOptions extends object, TFormatter>(
|
||||||
|
createFormatter: (options: TOptions) => TFormatter
|
||||||
|
) {
|
||||||
|
const formatters = new Map<string, TFormatter>()
|
||||||
|
|
||||||
|
return (options: TOptions = {} as TOptions) => {
|
||||||
|
const key = JSON.stringify(options)
|
||||||
|
const cachedFormatter = formatters.get(key)
|
||||||
|
|
||||||
|
if (cachedFormatter) {
|
||||||
|
return cachedFormatter
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatter = createFormatter(options)
|
||||||
|
formatters.set(key, formatter)
|
||||||
|
|
||||||
|
return formatter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIntlFormatters(
|
||||||
|
locale: string | readonly string[]
|
||||||
|
): IntlFormatters {
|
||||||
|
const locales = typeof locale === "string" ? locale : [...locale]
|
||||||
|
const getCollator = createFormatterCache(
|
||||||
|
(options) => new Intl.Collator(locales, options)
|
||||||
|
)
|
||||||
|
const getDateTimeFormatter = createFormatterCache(
|
||||||
|
(options) => new Intl.DateTimeFormat(locales, options)
|
||||||
|
)
|
||||||
|
const getListFormatter = createFormatterCache(
|
||||||
|
(options) => new Intl.ListFormat(locales, options)
|
||||||
|
)
|
||||||
|
const getNumberFormatter = createFormatterCache(
|
||||||
|
(options) => new Intl.NumberFormat(locales, options)
|
||||||
|
)
|
||||||
|
const getRelativeTimeFormatter = createFormatterCache(
|
||||||
|
(options) => new Intl.RelativeTimeFormat(locales, options)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
compare: (left, right, options) =>
|
||||||
|
getCollator(options).compare(left, right),
|
||||||
|
formatCurrency: (value, currency, options) =>
|
||||||
|
getNumberFormatter({
|
||||||
|
...options,
|
||||||
|
currency,
|
||||||
|
style: "currency",
|
||||||
|
}).format(value),
|
||||||
|
formatDate: (value, options) => getDateTimeFormatter(options).format(value),
|
||||||
|
formatList: (values, options) => getListFormatter(options).format(values),
|
||||||
|
formatNumber: (value, options) => getNumberFormatter(options).format(value),
|
||||||
|
formatRelativeTime: (value, unit, options) =>
|
||||||
|
getRelativeTimeFormatter(options).format(value, unit),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,14 @@ import { render, screen } from "@testing-library/react"
|
|||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
import { I18nProvider } from "./i18n-provider"
|
import { I18nProvider } from "./i18n-provider"
|
||||||
import { useLocale, useLocales, useMessage, useTranslate } from "./hooks"
|
import {
|
||||||
|
useFormatters,
|
||||||
|
useLocale,
|
||||||
|
useLocaleDefinition,
|
||||||
|
useLocales,
|
||||||
|
useMessage,
|
||||||
|
useTranslate,
|
||||||
|
} from "./hooks"
|
||||||
import { Translate } from "./translate"
|
import { Translate } from "./translate"
|
||||||
|
|
||||||
const catalogs = {
|
const catalogs = {
|
||||||
@@ -26,7 +33,7 @@ function HookProbe() {
|
|||||||
return (
|
return (
|
||||||
<output>
|
<output>
|
||||||
{locale}|{locales.map((item) => item.locale).join(",")}|{message}|
|
{locale}|{locales.map((item) => item.locale).join(",")}|{message}|
|
||||||
{translate("greeting")}
|
{translate({ id: "greeting", message: "Greeting" })}
|
||||||
</output>
|
</output>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -49,6 +56,34 @@ describe("i18n runtime", () => {
|
|||||||
expect(screen.getByText("zh-Hans|en,zh-Hans|你好|你好")).toBeTruthy()
|
expect(screen.getByText("zh-Hans|en,zh-Hans|你好|你好")).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("merges package catalogs and lets application catalogs override them", () => {
|
||||||
|
render(
|
||||||
|
<I18nProvider
|
||||||
|
catalogs={[
|
||||||
|
{
|
||||||
|
"zh-Hans": {
|
||||||
|
greeting: "包内翻译",
|
||||||
|
packageOnly: "来自组件包",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"zh-Hans": {
|
||||||
|
greeting: "应用翻译",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
locale="zh-Hans"
|
||||||
|
>
|
||||||
|
<output>
|
||||||
|
<Translate id="greeting" message="Greeting" />|
|
||||||
|
<Translate id="packageOnly" message="Package only" />
|
||||||
|
</output>
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText("应用翻译|来自组件包")).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
it("wraps Lingui Trans without adding a DOM element", () => {
|
it("wraps Lingui Trans without adding a DOM element", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<I18nProvider catalogs={catalogs} locale="en">
|
<I18nProvider catalogs={catalogs} locale="en">
|
||||||
@@ -74,4 +109,75 @@ describe("i18n runtime", () => {
|
|||||||
|
|
||||||
expect(screen.getByText("rtl")).toBeTruthy()
|
expect(screen.getByText("rtl")).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("uses localized language names when locale labels are omitted", () => {
|
||||||
|
function LocaleLabelsProbe() {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{useLocales()
|
||||||
|
.map((locale) => locale.label)
|
||||||
|
.join("|")}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<I18nProvider locale="en" locales={["en", "zh-Hans"]}>
|
||||||
|
<LocaleLabelsProbe />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText("English|简体中文")).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps catalog locales separate from Intl language tags", () => {
|
||||||
|
function LocaleProbe() {
|
||||||
|
const locale = useLocaleDefinition()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{locale.locale}|{locale.languageTag}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<I18nProvider
|
||||||
|
locale="zh-Hans"
|
||||||
|
locales={[{ languageTag: "zh-CN", locale: "zh-Hans" }]}
|
||||||
|
>
|
||||||
|
<LocaleProbe />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText("zh-Hans|zh-CN")).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("formats standard internationalized values through Intl", () => {
|
||||||
|
function FormatterProbe() {
|
||||||
|
const formatters = useFormatters()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<output>
|
||||||
|
{formatters.formatNumber(1234.5)}|
|
||||||
|
{formatters.formatCurrency(1234.5, "USD")}|
|
||||||
|
{formatters.formatList(["A", "B", "C"])}|
|
||||||
|
{formatters.formatRelativeTime(-1, "day", { numeric: "auto" })}
|
||||||
|
</output>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<I18nProvider
|
||||||
|
locale="en"
|
||||||
|
locales={[{ languageTag: "en-US", locale: "en" }]}
|
||||||
|
>
|
||||||
|
<FormatterProbe />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("1,234.5|$1,234.50|A, B, and C|yesterday")
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { Trans, type TransProps } from "@lingui/react"
|
import { Trans, type TransProps } from "@lingui/react"
|
||||||
|
|
||||||
export type TranslateProps = TransProps
|
import type { MessageDescriptor } from "./types"
|
||||||
|
|
||||||
|
export type TranslateProps = Omit<
|
||||||
|
TransProps,
|
||||||
|
"comment" | "id" | "message" | "values"
|
||||||
|
> &
|
||||||
|
MessageDescriptor
|
||||||
|
|
||||||
export function Translate(props: TranslateProps) {
|
export function Translate(props: TranslateProps) {
|
||||||
return <Trans {...props} />
|
return <Trans {...(props as TransProps)} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,77 @@
|
|||||||
import type {
|
export type MessageId = string
|
||||||
AllMessages,
|
export type MessageValues = Record<string, unknown>
|
||||||
I18n,
|
|
||||||
MessageDescriptor,
|
|
||||||
MessageId,
|
|
||||||
MessageOptions,
|
|
||||||
Messages,
|
|
||||||
} from "@lingui/core"
|
|
||||||
|
|
||||||
export type {
|
export interface MessageDescriptor {
|
||||||
AllMessages,
|
id: MessageId
|
||||||
I18n,
|
message: string
|
||||||
MessageDescriptor,
|
comment?: string
|
||||||
MessageId,
|
values?: MessageValues
|
||||||
MessageOptions,
|
|
||||||
Messages,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MessageOptions {
|
||||||
|
comment?: string
|
||||||
|
formats?: Record<
|
||||||
|
string,
|
||||||
|
Intl.DateTimeFormatOptions | Intl.NumberFormatOptions
|
||||||
|
>
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageCatalog = Record<string, unknown>
|
||||||
|
export type MessageCatalogs = Record<string, MessageCatalog>
|
||||||
|
|
||||||
|
export type MissingTranslationHandler =
|
||||||
|
string | ((locale: string, id: string) => string)
|
||||||
|
|
||||||
export type LocaleDirection = "ltr" | "rtl"
|
export type LocaleDirection = "ltr" | "rtl"
|
||||||
|
|
||||||
export interface LocaleDefinition {
|
export interface LocaleDefinition {
|
||||||
direction?: LocaleDirection
|
direction?: LocaleDirection
|
||||||
label?: string
|
label?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message catalog identifier, for example `zh-Hans`.
|
||||||
|
*/
|
||||||
locale: string
|
locale: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The BCP-47 language tag used by Intl formatters, for example `zh-CN`.
|
||||||
|
* Defaults to `locale`.
|
||||||
|
*/
|
||||||
|
languageTag?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocaleInput = LocaleDefinition | string
|
export type LocaleInput = LocaleDefinition | string
|
||||||
|
|
||||||
export type TranslateFunction = I18n["_"]
|
export type TranslateFunction = (
|
||||||
|
descriptor: MessageDescriptor,
|
||||||
|
values?: MessageValues
|
||||||
|
) => string
|
||||||
|
|
||||||
|
export interface IntlFormatters {
|
||||||
|
compare(left: string, right: string, options?: Intl.CollatorOptions): number
|
||||||
|
|
||||||
|
formatCurrency(
|
||||||
|
value: number | bigint,
|
||||||
|
currency: string,
|
||||||
|
options?: Omit<Intl.NumberFormatOptions, "currency" | "style">
|
||||||
|
): string
|
||||||
|
|
||||||
|
formatDate(value: Date | number, options?: Intl.DateTimeFormatOptions): string
|
||||||
|
|
||||||
|
formatList(
|
||||||
|
values: readonly string[],
|
||||||
|
options?: Intl.ListFormatOptions
|
||||||
|
): string
|
||||||
|
|
||||||
|
formatNumber(
|
||||||
|
value: number | bigint,
|
||||||
|
options?: Intl.NumberFormatOptions
|
||||||
|
): string
|
||||||
|
|
||||||
|
formatRelativeTime(
|
||||||
|
value: number,
|
||||||
|
unit: Intl.RelativeTimeFormatUnit,
|
||||||
|
options?: Intl.RelativeTimeFormatOptions
|
||||||
|
): string
|
||||||
|
}
|
||||||
|
|||||||
+251
-10
@@ -1,32 +1,273 @@
|
|||||||
import type { Plugin } from "vite"
|
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
||||||
|
import { dirname, join } from "node:path"
|
||||||
|
import type { Plugin, ResolvedConfig } from "vite"
|
||||||
|
|
||||||
|
import {
|
||||||
|
loadProjectCatalogs,
|
||||||
|
resolveCatalogSourceFilenames,
|
||||||
|
} from "./build/catalog-sources.ts"
|
||||||
|
import {
|
||||||
|
compactMessageCatalogs,
|
||||||
|
createMessageIdSchema,
|
||||||
|
type MessageIdSchema,
|
||||||
|
} from "./build/production-catalogs.ts"
|
||||||
import { configureI18nApi } from "./cli/api-plugin.ts"
|
import { configureI18nApi } from "./cli/api-plugin.ts"
|
||||||
import { resolveProjectFilename } from "./cli/project.ts"
|
import {
|
||||||
|
getProjectRoot,
|
||||||
|
readProject,
|
||||||
|
resolveProjectFilename,
|
||||||
|
} from "./cli/project.ts"
|
||||||
|
import type { I18nProjectConfig } from "./config/lingui-config.ts"
|
||||||
|
import type { MessageCatalogs } from "./runtime/types.ts"
|
||||||
|
|
||||||
|
const CATALOG_MODULE_ID = "@workspace/i18n/catalogs"
|
||||||
|
const RESOLVED_CATALOG_MODULE_ID = "\0workspace-i18n:catalogs"
|
||||||
|
const REACT_EXTERNAL_STORE_SHIM_ID = "use-sync-external-store/shim"
|
||||||
|
const RESOLVED_REACT_EXTERNAL_STORE_SHIM_ID =
|
||||||
|
"\0workspace-i18n:react-external-store-shim"
|
||||||
|
|
||||||
export interface I18nPluginOptions {
|
export interface I18nPluginOptions {
|
||||||
project?: string
|
project?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeFileIfChanged(filename: string, content: string) {
|
||||||
|
let currentContent: string | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentContent = await readFile(filename, "utf8")
|
||||||
|
} catch {
|
||||||
|
// The generated file does not exist yet.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentContent === content) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(dirname(filename), { recursive: true })
|
||||||
|
await writeFile(filename, content, "utf8")
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCatalogLoaderRuntime(resolveCatalog: string) {
|
||||||
|
return `
|
||||||
|
const catalogPromises = new Map()
|
||||||
|
|
||||||
|
export function loadMessageCatalog(locale) {
|
||||||
|
let promise = catalogPromises.get(locale)
|
||||||
|
|
||||||
|
if (!promise) {
|
||||||
|
promise = Promise.resolve().then(() => ${resolveCatalog})
|
||||||
|
catalogPromises.set(locale, promise)
|
||||||
|
}
|
||||||
|
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDevelopmentCatalogModule(
|
||||||
|
sourcesByLocale: Readonly<Record<string, readonly string[]>>
|
||||||
|
) {
|
||||||
|
const imports: string[] = []
|
||||||
|
const catalogs: string[] = []
|
||||||
|
let importIndex = 0
|
||||||
|
|
||||||
|
for (const [locale, filenames] of Object.entries(sourcesByLocale)) {
|
||||||
|
const identifiers = filenames.map((filename) => {
|
||||||
|
const identifier = `catalog${importIndex}`
|
||||||
|
|
||||||
|
importIndex += 1
|
||||||
|
imports.push(
|
||||||
|
`import { messages as ${identifier} } from ${JSON.stringify(filename)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return identifier
|
||||||
|
})
|
||||||
|
|
||||||
|
catalogs.push(
|
||||||
|
`${JSON.stringify(locale)}: Object.assign({}, ${identifiers.join(", ")})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${imports.join("\n")}
|
||||||
|
|
||||||
|
const catalogs = { ${catalogs.join(", ")} }
|
||||||
|
${createCatalogLoaderRuntime(
|
||||||
|
"catalogs[locale] ?? Promise.reject(new Error(`Unknown i18n locale: ${locale}`))"
|
||||||
|
)}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServerCatalogModule(catalogs: MessageCatalogs) {
|
||||||
|
return `const catalogs = ${JSON.stringify(catalogs)}
|
||||||
|
${createCatalogLoaderRuntime(
|
||||||
|
"catalogs[locale] ?? Promise.reject(new Error(`Unknown i18n locale: ${locale}`))"
|
||||||
|
)}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClientCatalogModule(
|
||||||
|
assetReferences: Readonly<Record<string, string>>
|
||||||
|
) {
|
||||||
|
const urls = Object.entries(assetReferences)
|
||||||
|
.map(
|
||||||
|
([locale, reference]) =>
|
||||||
|
`${JSON.stringify(locale)}: import.meta.ROLLUP_FILE_URL_${reference}`
|
||||||
|
)
|
||||||
|
.join(", ")
|
||||||
|
|
||||||
|
return `const catalogUrls = { ${urls} }
|
||||||
|
${createCatalogLoaderRuntime(`(async () => {
|
||||||
|
const url = catalogUrls[locale]
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(\`Unknown i18n locale: \${locale}\`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
\`Unable to load i18n catalog for \${locale}: \${response.status}\`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
})()`)}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceMessageIds(code: string, schema: MessageIdSchema) {
|
||||||
|
let transformedCode = code
|
||||||
|
|
||||||
|
for (const [id, compactId] of Object.entries(schema.messages).sort(
|
||||||
|
([left], [right]) => right.length - left.length
|
||||||
|
)) {
|
||||||
|
transformedCode = transformedCode.replaceAll(
|
||||||
|
JSON.stringify(id),
|
||||||
|
JSON.stringify(compactId)
|
||||||
|
)
|
||||||
|
transformedCode = transformedCode.replaceAll(
|
||||||
|
`'${id.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`,
|
||||||
|
`'${compactId}'`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformedCode
|
||||||
|
}
|
||||||
|
|
||||||
export function i18n(options: I18nPluginOptions = {}): Plugin {
|
export function i18n(options: I18nPluginOptions = {}): Plugin {
|
||||||
let projectFilename: string | undefined
|
let config: ResolvedConfig
|
||||||
|
let project: I18nProjectConfig
|
||||||
|
let projectFilename: string
|
||||||
|
let projectRoot: string
|
||||||
|
let compactCatalogs: MessageCatalogs | undefined
|
||||||
|
let schema: MessageIdSchema | undefined
|
||||||
|
let sourcesByLocale: Record<string, readonly string[]> | undefined
|
||||||
|
let assetReferences: Record<string, string> = {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "workspace-i18n",
|
name: "workspace-i18n",
|
||||||
apply: "serve",
|
enforce: "pre",
|
||||||
async configResolved(config) {
|
async configResolved(resolvedConfig) {
|
||||||
|
config = resolvedConfig
|
||||||
projectFilename = await resolveProjectFilename(
|
projectFilename = await resolveProjectFilename(
|
||||||
options.project,
|
options.project,
|
||||||
config.root
|
config.root
|
||||||
)
|
)
|
||||||
|
projectRoot = getProjectRoot(projectFilename)
|
||||||
|
project = await readProject(projectFilename)
|
||||||
},
|
},
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
if (!projectFilename) {
|
configureI18nApi(server, projectFilename)
|
||||||
throw new Error(
|
},
|
||||||
"The @workspace/i18n Vite plugin could not resolve i18n.config.json."
|
resolveId(id) {
|
||||||
)
|
if (id === CATALOG_MODULE_ID) {
|
||||||
|
return RESOLVED_CATALOG_MODULE_ID
|
||||||
}
|
}
|
||||||
|
|
||||||
configureI18nApi(server, projectFilename)
|
// Lingui supports older React versions through the external-store shim.
|
||||||
|
// This workspace requires React 19, so importing the built-in hook keeps
|
||||||
|
// Nitro SSR on the same React instance as the renderer.
|
||||||
|
if (id === REACT_EXTERNAL_STORE_SHIM_ID) {
|
||||||
|
return RESOLVED_REACT_EXTERNAL_STORE_SHIM_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
async buildStart() {
|
||||||
|
if (config.command !== "build") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadedCatalogs = await loadProjectCatalogs(project, projectRoot)
|
||||||
|
|
||||||
|
sourcesByLocale = loadedCatalogs.sourcesByLocale
|
||||||
|
schema = createMessageIdSchema(loadedCatalogs.catalogs)
|
||||||
|
compactCatalogs = compactMessageCatalogs(loadedCatalogs.catalogs, schema)
|
||||||
|
assetReferences = {}
|
||||||
|
|
||||||
|
for (const locale of project.locales) {
|
||||||
|
assetReferences[locale] = this.emitFile({
|
||||||
|
name: `i18n/${locale}.json`,
|
||||||
|
source: JSON.stringify(compactCatalogs[locale] ?? {}),
|
||||||
|
type: "asset",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFileIfChanged(
|
||||||
|
join(projectRoot, ".i18n", "schema.json"),
|
||||||
|
`${JSON.stringify(schema, null, 2)}\n`
|
||||||
|
)
|
||||||
|
},
|
||||||
|
async load(id, loadOptions) {
|
||||||
|
if (id === RESOLVED_REACT_EXTERNAL_STORE_SHIM_ID) {
|
||||||
|
return 'export { useSyncExternalStore } from "react"'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id !== RESOLVED_CATALOG_MODULE_ID) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.command === "build") {
|
||||||
|
if (!compactCatalogs) {
|
||||||
|
throw new Error(
|
||||||
|
"Production i18n catalogs were not prepared before loading the catalog module."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadOptions?.ssr
|
||||||
|
? createServerCatalogModule(compactCatalogs)
|
||||||
|
: createClientCatalogModule(assetReferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourcesByLocale) {
|
||||||
|
const resolvedSourcesByLocale = Object.fromEntries(
|
||||||
|
await Promise.all(
|
||||||
|
project.locales.map(async (locale) => [
|
||||||
|
locale,
|
||||||
|
await resolveCatalogSourceFilenames(project, projectRoot, locale),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
sourcesByLocale = resolvedSourcesByLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
return createDevelopmentCatalogModule(sourcesByLocale!)
|
||||||
|
},
|
||||||
|
renderChunk(code) {
|
||||||
|
if (config.command !== "build" || !schema) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformedCode = replaceMessageIds(code, schema)
|
||||||
|
|
||||||
|
return transformedCode === code
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
code: transformedCode,
|
||||||
|
map: null,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,6 @@
|
|||||||
"@workspace/i18n/*": ["./src/*"]
|
"@workspace/i18n/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "lingui.config.ts"],
|
"include": ["src"],
|
||||||
"exclude": ["node_modules", "dist", "locales"]
|
"exclude": ["node_modules", "dist", "locales"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user